diff --git a/README.md b/README.md index d117c6f..1b88198 100644 --- a/README.md +++ b/README.md @@ -24,3 +24,8 @@ The website will be available at `http://localhost:9810`. This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Attribution + +This website is built using the [Bootstrap](https://getbootstrap.com) framework +and [Phosphor Icons](https://phosphoricons.com). \ No newline at end of file diff --git a/assets/css/base.css b/assets/css/base.css index 94aa844..8481a04 100644 --- a/assets/css/base.css +++ b/assets/css/base.css @@ -85,6 +85,48 @@ h5 { margin-top: 10px; } +.currency-col { + width: 200px; + white-space: nowrap; + text-align: right; +} + +.table-transparency td:not(:first-child) { + text-align: right; +} + +.section { + padding: 20px 0; + border-bottom: 1px solid #e0e0e0; +} + +.alert-warning { + background-color: #fff3cd; + border-color: #ffeeba; + color: #856404; + padding: 15px; + margin-bottom: 20px; + border-radius: 4px; +} + +.alert-warning .alert-link { + color: #856404; + font-weight: bold; + text-decoration: underline; +} + +.alert-warning .alert-link:hover { + color: #604c2e; +} + +.bs-icon.bs-icon-primary svg { + fill: var(--bs-primary-bg-subtle); +} + +.bs-icon.bs-icon-lg svg { + fill: var(--bs-primary) +} + /* Responsive Styles */ @media (max-width: 768px) { .navbar .container { diff --git a/assets/dist/icons/article-ny-times.svg b/assets/dist/icons/article-ny-times.svg new file mode 100644 index 0000000..f4ed00a --- /dev/null +++ b/assets/dist/icons/article-ny-times.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/calendar.svg b/assets/dist/icons/calendar.svg new file mode 100644 index 0000000..c066f4a --- /dev/null +++ b/assets/dist/icons/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/chats.svg b/assets/dist/icons/chats.svg new file mode 100644 index 0000000..2d1e90a --- /dev/null +++ b/assets/dist/icons/chats.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/coffee.svg b/assets/dist/icons/coffee.svg new file mode 100644 index 0000000..4d4e30a --- /dev/null +++ b/assets/dist/icons/coffee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/database.svg b/assets/dist/icons/database.svg new file mode 100644 index 0000000..8d916c3 --- /dev/null +++ b/assets/dist/icons/database.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/envelope.svg b/assets/dist/icons/envelope.svg new file mode 100644 index 0000000..30b1cee --- /dev/null +++ b/assets/dist/icons/envelope.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/git-branch.svg b/assets/dist/icons/git-branch.svg new file mode 100644 index 0000000..1b917dd --- /dev/null +++ b/assets/dist/icons/git-branch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/images.svg b/assets/dist/icons/images.svg new file mode 100644 index 0000000..65a3f60 --- /dev/null +++ b/assets/dist/icons/images.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/leaf.svg b/assets/dist/icons/leaf.svg new file mode 100644 index 0000000..e9cfc46 --- /dev/null +++ b/assets/dist/icons/leaf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/lightbulb.svg b/assets/dist/icons/lightbulb.svg new file mode 100644 index 0000000..918dbe2 --- /dev/null +++ b/assets/dist/icons/lightbulb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/link.svg b/assets/dist/icons/link.svg new file mode 100644 index 0000000..da45a28 --- /dev/null +++ b/assets/dist/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/magnifying-glass.svg b/assets/dist/icons/magnifying-glass.svg new file mode 100644 index 0000000..bf4e505 --- /dev/null +++ b/assets/dist/icons/magnifying-glass.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/markdown-logo.svg b/assets/dist/icons/markdown-logo.svg new file mode 100644 index 0000000..7a14e6b --- /dev/null +++ b/assets/dist/icons/markdown-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/mastodon-logo.svg b/assets/dist/icons/mastodon-logo.svg new file mode 100644 index 0000000..313c57c --- /dev/null +++ b/assets/dist/icons/mastodon-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/matrix-logo.svg b/assets/dist/icons/matrix-logo.svg new file mode 100644 index 0000000..3a0190d --- /dev/null +++ b/assets/dist/icons/matrix-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/network.svg b/assets/dist/icons/network.svg new file mode 100644 index 0000000..62b51c5 --- /dev/null +++ b/assets/dist/icons/network.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/pencil-ruler.svg b/assets/dist/icons/pencil-ruler.svg new file mode 100644 index 0000000..c66af52 --- /dev/null +++ b/assets/dist/icons/pencil-ruler.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/video.svg b/assets/dist/icons/video.svg new file mode 100644 index 0000000..d711517 --- /dev/null +++ b/assets/dist/icons/video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/dist/icons/webcam.svg b/assets/dist/icons/webcam.svg new file mode 100644 index 0000000..abd8965 --- /dev/null +++ b/assets/dist/icons/webcam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/finances.json b/data/finances.json new file mode 100644 index 0000000..b047d7a --- /dev/null +++ b/data/finances.json @@ -0,0 +1,33 @@ +{ + "2024": { + "4": { + "Membership Fees": { + "EUR": 365 + }, + "Donations": {}, + "Server Costs": { + "EUR": -216.57 + }, + "Domain Names": {}, + "Operating Expenses": { + "EUR": -36.10 + }, + "Conversions": {} + }, + "5": { + "Membership Fees": { + "EUR": 390 + }, + "Donations": { + "BTC": 0.000434, + "XMR": 0.447661805527 + }, + "Server Costs": { + "EUR": -430.04 + }, + "Domain Names": {}, + "Operating Expenses": {}, + "Conversions": {} + } + } +} \ No newline at end of file diff --git a/services.json b/data/services.json similarity index 89% rename from services.json rename to data/services.json index 7a20116..2e4dc7d 100644 --- a/services.json +++ b/data/services.json @@ -12,6 +12,7 @@ "url": "https://element.private.coffee" } ], + "icon": "matrix-logo", "exclude_from_index": false, "exclude_from_simple": true }, @@ -27,6 +28,7 @@ "url": "https://cryptpad.private.coffee" } ], + "icon": "article-ny-times", "exclude_from_index": false, "exclude_from_simple": false }, @@ -42,6 +44,7 @@ "url": "https://piped.private.coffee" } ], + "icon": "video", "exclude_from_index": false, "exclude_from_simple": false }, @@ -57,6 +60,7 @@ "url": "https://myip.coffee" } ], + "icon": "network", "exclude_from_index": false, "exclude_from_simple": false }, @@ -72,6 +76,7 @@ "url": "https://invidious.private.coffee" } ], + "icon": "video", "exclude_from_index": false, "exclude_from_simple": false }, @@ -91,6 +96,7 @@ "url": "https://skrt.social" } ], + "icon": "mastodon-logo", "exclude_from_index": false, "exclude_from_simple": false }, @@ -106,6 +112,7 @@ "url": "https://git.private.coffee" } ], + "icon": "git-branch", "exclude_from_index": false, "exclude_from_simple": false }, @@ -121,6 +128,7 @@ "url": "https://bbb.private.coffee" } ], + "icon": "webcam", "exclude_from_index": false, "exclude_from_simple": false }, @@ -136,6 +144,7 @@ "url": "https://hedgedoc.private.coffee" } ], + "icon": "markdown-logo", "exclude_from_index": false, "exclude_from_simple": false }, @@ -151,6 +160,7 @@ "url": "https://gothub.private.coffee" } ], + "icon": "git-branch", "exclude_from_index": false, "exclude_from_simple": false }, @@ -166,6 +176,7 @@ "url": "https://redlib.private.coffee" } ], + "icon": "chats", "exclude_from_index": false, "exclude_from_simple": false }, @@ -181,6 +192,7 @@ "url": "https://alltube.private.coffee" } ], + "icon": "video", "exclude_from_index": false, "exclude_from_simple": false }, @@ -196,6 +208,7 @@ "url": "https://structables.private.coffee" } ], + "icon": "lightbulb", "exclude_from_index": false, "exclude_from_simple": false }, @@ -211,6 +224,7 @@ "url": "https://nocodb.private.coffee" } ], + "icon": "database", "exclude_from_index": true, "exclude_from_simple": true }, @@ -226,6 +240,7 @@ "url": "https://penpot.private.coffee" } ], + "icon": "pencil-ruler", "exclude_from_index": false, "exclude_from_simple": false }, @@ -241,6 +256,7 @@ "url": "https://pcof.fi" } ], + "icon": "link", "exclude_from_index": false, "exclude_from_simple": false }, @@ -256,6 +272,7 @@ "url": "https://rallly.private.coffee" } ], + "icon": "calendar", "exclude_from_index": true, "exclude_from_simple": true }, @@ -271,6 +288,7 @@ "url": "https://librey.private.coffee" } ], + "icon": "magnifying-glass", "exclude_from_index": false, "exclude_from_simple": false }, @@ -286,6 +304,7 @@ "url": "https://overleaf.private.coffee" } ], + "icon": "leaf", "exclude_from_index": false, "exclude_from_simple": false }, @@ -301,44 +320,9 @@ "url": "https://binternet.private.coffee" } ], + "icon": "images", "exclude_from_index": false, "exclude_from_simple": false - }, - { - "name": "Nitter", - "url": "https://nitter.private.coffee", - "short_description": "Nitter is a privacy-friendly alternative front-end to Twitter.", - "long_description": "Nitter is a privacy-friendly alternative front-end to Twitter. It allows you to browse Twitter without being tracked.", - "status": "NOK", - "links": [ - { - "name": "Go to Nitter", - "url": "https://nitter.private.coffee", - "alternatives": [ - { - "name": "Tor (.onion)", - "url": "http://nitter.coffee2m3bjsrrqqycx6ghkxrnejl2q6nl7pjw2j4clchjj6uk5zozad.onion/" - } - ] - } - ], - "exclude_from_index": true, - "exclude_from_simple": true - }, - { - "name": "Proxigram", - "url": "https://proxigram.private.coffee", - "short_description": "Proxigram is a privacy-friendly alternative front-end to Instagram.", - "long_description": "Proxigram is a privacy-friendly alternative front-end to Instagram. It allows you to browse Instagram without being tracked.", - "status": "NOK", - "links": [ - { - "name": "Go to Proxigram", - "url": "https://proxigram.private.coffee" - } - ], - "exclude_from_index": true, - "exclude_from_simple": true } ] } \ No newline at end of file diff --git a/helpers/finances.py b/helpers/finances.py new file mode 100644 index 0000000..e4a0b32 --- /dev/null +++ b/helpers/finances.py @@ -0,0 +1,201 @@ +from decimal import Decimal + + +def get_latest_month(data): + years = sorted(data.keys()) + latest_year = years[-1] + months = sorted(data[latest_year].keys()) + latest_month = months[-1] + + return int(latest_month), int(latest_year) + +def get_transparency_data(data, year=None, month=None): + if year is None: + year = max(data.keys()) + + if month is None: + month = max(data[year].keys()) + + assert year in data, f"Year {year} not found in data" + assert month in data[year], f"Month {month}-{year} not found in data" + + # Initialize balances + balances = {} + incomes = {} + expenses = {} + + start_balance = {} + end_balance = {} + + for y in sorted(data.keys()): + if int(y) > int(year): + break + + for m in sorted(data[y].keys()): + if int(y) == int(year) and int(m) > int(month): + break + + # If the month is the one we are interested in, capture the start balance + if int(y) == int(year) and int(m) == int(month): + start_balance = {k: Decimal(v) for k, v in balances.items()} + + for category in data[y][m]: + for currency, amount in data[y][m][category].items(): + if currency not in balances: + balances[currency] = Decimal(0) + balances[currency] += Decimal(str(amount)) + + # Track incomes and expenses + if int(y) == int(year) and int(m) == int(month): + if Decimal(str(amount)) > 0: + if category not in incomes: + incomes[category] = {} + if currency not in incomes[category]: + incomes[category][currency] = Decimal(0) + incomes[category][currency] += Decimal(str(amount)) + else: + if category not in expenses: + expenses[category] = {} + if currency not in expenses[category]: + expenses[category][currency] = Decimal(0) + expenses[category][currency] += Decimal(str(amount)) + + # If the month is the one we are interested in, capture the end balance + if int(y) == int(year) and int(m) == int(month): + end_balance = {k: Decimal(v) for k, v in balances.items()} + + # Calculate accumulated sums of incomes and expenses + accumulated_incomes = { + currency: sum(incomes[cat].get(currency, Decimal(0)) for cat in incomes) + for currency in balances + } + accumulated_expenses = { + currency: sum(expenses[cat].get(currency, Decimal(0)) for cat in expenses) + for currency in balances + } + + return { + "start_balance": start_balance, + "end_balance": end_balance, + "incomes": incomes, + "expenses": expenses, + "accumulated_incomes": accumulated_incomes, + "accumulated_expenses": accumulated_expenses, + } + + +def generate_transparency_table(result, currencies=None): + def extract_currencies(data): + return ["EUR"] + ( + list( + set( + list(data["start_balance"].keys()) + + list(data["end_balance"].keys()) + + list(data["accumulated_incomes"].keys()) + + list(data["accumulated_expenses"].keys()) + ) + - {"EUR"} + ) + ) + + def format_currency(value, currency): + if currency == "EUR": + return f"€{value:,.2f}" + elif currency in ["BTC", "ETH", "XMR"]: + return f"{value:,.9f} {currency}" + else: + return f"{value} {currency}" + + def format_value(value, currency): + if value == 0: + return f"{format_currency(value, currency)}" + elif value > 0: + return f"+ {format_currency(value, currency)}" + else: + return f"- {format_currency(abs(value), currency)}" + + html = """ + + + + + """ + + if currencies is None: + currencies = extract_currencies(result) + + # Add currency headers + for currency in currencies: + if currency == "EUR": + html += '' + elif currency == "BTC": + html += '' + elif currency == "ETH": + html += '' + elif currency == "XMR": + html += '' + else: + html += f'' + + html += """ + + + + """ + + # Add start balance row + html += "" + for currency in currencies: + value = result["start_balance"].get(currency, Decimal(0)) + html += f"" + html += "" + + # Add income rows + for category, transactions in result["incomes"].items(): + html += f"" + for currency in currencies: + value = transactions.get(currency, "") + if value != "": + html += f"" + else: + html += "" + html += "" + + # Add expense rows + for category, transactions in result["expenses"].items(): + html += f"" + for currency in currencies: + value = transactions.get(currency, "") + if value != "": + html += f"" + else: + html += "" + html += "" + + # Add total income row + html += '' + for currency in currencies: + value = result["accumulated_incomes"].get(currency, Decimal(0)) + html += f"" + html += "" + + # Add total expenses row + html += '' + for currency in currencies: + value = result["accumulated_expenses"].get(currency, Decimal(0)) + html += f"" + html += "" + + # Add end balance row + html += '' + for currency in currencies: + value = result["end_balance"].get(currency, Decimal(0)) + html += f"" + html += "" + + html += """ + +
CategoryEuros (€)Bitcoin (BTC)Ethereum (ETH)Monero (XMR){currency}
Account Balance (start of month){format_value(value, currency)}
{category}{format_value(value, currency)}
{category}{format_value(value, currency)}
Total Income{format_value(value, currency)}
Total Expenses{format_value(value, currency)}
Account Balance (end of month){format_value(value, currency)}
+ """ + + return html diff --git a/main.py b/main.py index f42bc36..6c9a279 100644 --- a/main.py +++ b/main.py @@ -3,9 +3,17 @@ from jinja2 import TemplateNotFound import json import pathlib +import os +import datetime from argparse import ArgumentParser +from helpers.finances import ( + generate_transparency_table, + get_transparency_data, + get_latest_month, +) + app = Flask(__name__) @@ -18,18 +26,79 @@ def send_assets(path): @app.route("/.html") def catch_all(path): try: - services = json.loads( - (pathlib.Path(__file__).parent / "services.json").read_text() - ) - return render_template(f"{path}.html", services=services) + kwargs = {} + + if app.development_mode: + kwargs.update( + { + "warning": render_template("prod-warning.html"), + } + ) + + if path in ( + "index", + "simple", + ): + services = json.loads( + (pathlib.Path(__file__).parent / "data" / "services.json").read_text() + ) + + kwargs.update( + { + "services": services, + } + ) + + if path == "membership": + finances = json.loads( + (pathlib.Path(__file__).parent / "data" / "finances.json").read_text() + ) + + finances_month, finances_year = get_latest_month(finances) + finances_period = datetime.date(finances_year, finances_month, 1) + finances_period_str = finances_period.strftime("%B %Y") + + finances_table = generate_transparency_table( + get_transparency_data(finances) + ) + + kwargs.update( + { + "finances": finances_table, + "finances_period": finances_period_str, + } + ) + + return render_template(f"{path}.html", **kwargs) + except TemplateNotFound: return "404 Not Found", 404 +app.development_mode = False + +if os.environ.get("PRIVATECOFFEE_DEV"): + app.development_mode = True + + +def icon(icon_name): + file = send_from_directory("assets", f"dist/icons/{icon_name}.svg") + try: + file_content = file.response.file.read().decode("utf-8") + except AttributeError: + file_content = file.response.read().decode("utf-8") + return file_content + + +app.add_template_filter(icon) + if __name__ == "__main__": parser = ArgumentParser(description="Run the private.coffee web server.") parser.add_argument("--port", type=int, default=9810) parser.add_argument("--debug", action="store_true") + parser.add_argument("--dev", action="store_true") args = parser.parse_args() + app.development_mode = args.dev or app.development_mode + app.run(port=args.port, debug=args.debug) diff --git a/templates/base.html b/templates/base.html index 34dc793..2a5fa97 100644 --- a/templates/base.html +++ b/templates/base.html @@ -51,6 +51,7 @@ JOIN & REBEL + {% if warning %}{{ warning|safe }}{% endif %} {% block content %}{% endblock %}