diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..880209a --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,78 @@ +name: tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Django tests + runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: twisted_test + POSTGRES_USER: twisted_test + POSTGRES_PASSWORD: twisted_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U twisted_test -d twisted_test" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DJANGO_SETTINGS_MODULE: mysite.test_settings + POSTGRES_DB: twisted_test + POSTGRES_HOST: 127.0.0.1 + POSTGRES_PASSWORD: twisted_test + POSTGRES_PORT: 5432 + POSTGRES_USER: twisted_test + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --frozen --dev + + - name: Run Django checks + run: | + uv run python twisted/manage.py check --settings=mysite.test_settings + uv run python twisted/manage.py makemigrations --check --dry-run --settings=mysite.test_settings + + - name: Run tests with coverage + run: | + uv run coverage run twisted/manage.py test twisted_site common --settings=mysite.test_settings --verbosity 2 + uv run coverage report + uv run coverage xml -o coverage.xml + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: python-coverage + path: coverage.xml + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 87264dc..85a7573 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ build/ dist/ wheels/ *.egg-info +.coverage +coverage.xml +htmlcov/ # Virtual environments .venv diff --git a/README.md b/README.md index bf17ce6..364055e 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,21 @@ wanna help dev? follow these steps! 2. [install uv](https://docs.astral.sh/uv/getting-started/installation/) 3. copy twisted/.env.example to twisted/.env +All instructions below assume you've already cd'd into the `twisted` dir + ### launching -1. run `cd twisted` -2. run `uv run manage.py migrate` -3. run `docker compose up db` -4. run `uv run manage.py tailwind dev` in a new terminal -5. -## Thanks -Thats all! Thanks to Heliodex for making this Hack Club ysws possible, if your interested in participating, your're more than welcome to do that :) +1. run `uv run manage.py migrate` +2. run `docker compose up db` +3. run `uv run manage.py tailwind dev` in a new terminal + +### getting an admin account +1. log into your account on the web +2. run `uv run manage.py shell_plus` or `uv run manage.py shell` depending on what you fancy +3. get your profile by running `profile = Profile.objects.get()` (assuming only one person has signed up) +4. run `profile.is_staff = True` +5. run `perms = ProfileStaffPermissions.objects.create(superuser = True, view_users = True, view_pathways = True, manage_pathways = True, manage_fulfillments = True, manage_shop = True, view_review = True, manage_review = True, manage_announcements = True, view_auditlogs = True)` (this creates a perms object with all perms) +6. run `profile.staff_permissions = perms` (this associates the perms object with your profile) +7. run `profile.save()` (this saves the changes to the DB) + +### testing +run `uv run manage.py test --settings mysite.test_settings` diff --git a/pyproject.toml b/pyproject.toml index 8030237..815a1ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "django-tailwind[cookiecutter,honcho,reload]>=4.5.0", "gunicorn>=26.0.0", "psycopg[binary]>=3.3.4", + "pillow>=11.3.0", "python-dotenv>=1.2.2", "requests>=2.34.2", "slack-bolt>=1.30.0", @@ -38,6 +39,7 @@ ignore = ["A002", "C901", "CPY001", "D100", "D101", "D102", "D103", "D104", "D10 [tool.ruff.lint.per-file-ignores] "*/migrations/*.py" = ["RUF012", "ALL"] +"*/tests/*.py" = ["PT009", "PT027", "S106"] [tool.pyright] venvPath = "." @@ -47,3 +49,24 @@ executionEnvironments = [{ root = "twisted" }] [tool.pyrefly] infer-with-first-use = false search-path = ["twisted"] +# Migrations are generated and excluded from ruff too (see per-file-ignores above). +project-excludes = ["**/migrations/**"] + +[tool.coverage.run] +branch = true +source = ["twisted"] +omit = [ + "*/migrations/*", + "*/tests/*", + "twisted/manage.py", +] + +[tool.coverage.report] +show_missing = true +skip_covered = true + +[dependency-groups] +dev = [ + "coverage[toml]>=7.9.0", + "django-stubs>=6.1.1", +] diff --git a/twisted/.env.example b/twisted/.env.example index 0c50b4d..fef04e8 100644 --- a/twisted/.env.example +++ b/twisted/.env.example @@ -15,7 +15,7 @@ HCA_REDIRECT_URI = http://localhost:8000/oauth/callback HACKATIME_CLIENT_ID = HACKATIME_CLIENT_SECRET = -HACKATIME_REDIRECT_URI = https://localhost:8000/oauth/hackatime_callback +HACKATIME_REDIRECT_URI = http://localhost:8000/oauth/hackatime_callback SLACK_TOKEN = xoxb-*** SLACK_LOG_CHANNEL = C*** diff --git a/twisted/mysite/middleware.py b/twisted/mysite/middleware.py index 89e594c..c5fe35a 100644 --- a/twisted/mysite/middleware.py +++ b/twisted/mysite/middleware.py @@ -23,7 +23,9 @@ def __call__(self, request: HttpRequest) -> HttpResponse: else: timezone.deactivate() except (zoneinfo.ZoneInfoNotFoundError, ValueError): - logger.warning("Invalid django_timezone cookie value: %r", request.COOKIES.get("django_timezone")) + logger.warning( + "Invalid django_timezone cookie value: %r", request.COOKIES.get("django_timezone"), + ) timezone.deactivate() return self.get_response(request) diff --git a/twisted/mysite/settings.py b/twisted/mysite/settings.py index 42669ed..48738de 100644 --- a/twisted/mysite/settings.py +++ b/twisted/mysite/settings.py @@ -66,6 +66,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.humanize", + "django.contrib.postgres", # Your apps "common", "twisted_site", diff --git a/twisted/mysite/test_settings.py b/twisted/mysite/test_settings.py new file mode 100644 index 0000000..a23a9dc --- /dev/null +++ b/twisted/mysite/test_settings.py @@ -0,0 +1,64 @@ +# ruff: noqa: E402 +# pyright: reportConstantRedefinition=false +"""Deterministic settings for the automated test suite.""" + +import os + +_TEST_ENVIRONMENT = { + "ALLOWED_HOSTS": "testserver,localhost,127.0.0.1", + "ARI_INGEST_ENDPOINT": "https://example.invalid/ari/", + "ARI_SIGNING_SECRET": "test-ari-signing-secret", + "ARI_WEBHOOK_SECRET": "test-ari-webhook-secret", + "CSRF_TRUSTED_ORIGINS": "http://testserver", + "DEBUG": "false", + "DEBUG_REVIEW": "false", + "DEFAULT_PFP": "https://example.invalid/avatar.png", + "HACKATIME_CLIENT_ID": "test-hackatime-client-id", + "HACKATIME_CLIENT_SECRET": "test-hackatime-client-secret", + "HACKATIME_REDIRECT_URI": "http://testserver/oauth/hackatime_callback", + "HCA_CLIENT_ID": "test-hca-client-id", + "HCA_CLIENT_SECRET": "test-hca-client-secret", + "HCA_REDIRECT_URI": "http://testserver/oauth/callback", + "LOGIN_ENABLED": "true", + "POSTGRES_DB": "django", + "POSTGRES_HOST": "127.0.0.1", + "POSTGRES_PASSWORD": "django", + "POSTGRES_PORT": "5432", + "POSTGRES_USER": "django", + "R2_ACCESS_KEY": "test-r2-access-key", + "R2_BUCKET": "test-r2-bucket", + "R2_ENDPOINT": "https://example.invalid/r2", + "R2_PUBLIC_URL": "https://example.invalid/uploads", + "R2_SECRET_KEY": "test-r2-secret-key", + "SECRET_KEY": "test-secret-key-that-is-only-used-by-the-automated-test-suite", + "SLACK_LOG_CHANNEL": "C_TEST", + "SLACK_TOKEN": "xoxb-test-token", +} + +for _name, _value in _TEST_ENVIRONMENT.items(): + _ = os.environ.setdefault(_name, _value) + +from mysite.settings import * # noqa: F403 + +DEBUG = False +DEBUG_REVIEW = False +SECURE_SSL_REDIRECT = False +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False + +MIDDLEWARE = [ + middleware + for middleware in MIDDLEWARE # noqa: F405 + if middleware != "whitenoise.middleware.WhiteNoiseMiddleware" +] + +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} diff --git a/twisted/tailwindcsstheme/static/icon/button-down-active.png b/twisted/tailwindcsstheme/static/icon/button-down-active.png deleted file mode 100644 index 7043be3..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/button-down-active.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/button-down.png b/twisted/tailwindcsstheme/static/icon/button-down.png deleted file mode 100644 index 36c31a1..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/button-down.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/button-left.png b/twisted/tailwindcsstheme/static/icon/button-left.png deleted file mode 100644 index 3a35c36..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/button-left.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/button-right.png b/twisted/tailwindcsstheme/static/icon/button-right.png deleted file mode 100644 index e9869f2..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/button-right.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/button-up.png b/twisted/tailwindcsstheme/static/icon/button-up.png deleted file mode 100644 index 7d161b8..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/button-up.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/checkmark-disabled.png b/twisted/tailwindcsstheme/static/icon/checkmark-disabled.png deleted file mode 100644 index 030099b..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/checkmark-disabled.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/checkmark.png b/twisted/tailwindcsstheme/static/icon/checkmark.png deleted file mode 100644 index ba31ee2..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/checkmark.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/close.png b/twisted/tailwindcsstheme/static/icon/close.png deleted file mode 100644 index 38bb61b..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/close.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/groupbox-border.png b/twisted/tailwindcsstheme/static/icon/groupbox-border.png deleted file mode 100644 index 274fd9e..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/groupbox-border.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/help.png b/twisted/tailwindcsstheme/static/icon/help.png deleted file mode 100644 index 337d604..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/help.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/indicator-horizontal.png b/twisted/tailwindcsstheme/static/icon/indicator-horizontal.png deleted file mode 100644 index 03e7197..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/indicator-horizontal.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/indicator-rectangle-horizontal.png b/twisted/tailwindcsstheme/static/icon/indicator-rectangle-horizontal.png deleted file mode 100644 index a790cf9..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/indicator-rectangle-horizontal.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/maximize-disabled.png b/twisted/tailwindcsstheme/static/icon/maximize-disabled.png deleted file mode 100644 index 01ecb73..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/maximize-disabled.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/maximize.png b/twisted/tailwindcsstheme/static/icon/maximize.png deleted file mode 100644 index 60c58f2..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/maximize.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/minimize.png b/twisted/tailwindcsstheme/static/icon/minimize.png deleted file mode 100644 index 9f61d89..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/minimize.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/radio-border-disabled.png b/twisted/tailwindcsstheme/static/icon/radio-border-disabled.png deleted file mode 100644 index 0234398..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/radio-border-disabled.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/radio-border.png b/twisted/tailwindcsstheme/static/icon/radio-border.png deleted file mode 100644 index 9a91c16..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/radio-border.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/radio-dot-disabled.png b/twisted/tailwindcsstheme/static/icon/radio-dot-disabled.png deleted file mode 100644 index 0b27d6d..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/radio-dot-disabled.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/radio-dot.png b/twisted/tailwindcsstheme/static/icon/radio-dot.png deleted file mode 100644 index 15c0988..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/radio-dot.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/restore.png b/twisted/tailwindcsstheme/static/icon/restore.png deleted file mode 100644 index 86dd6f5..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/restore.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/scrollbar-background.png b/twisted/tailwindcsstheme/static/icon/scrollbar-background.png deleted file mode 100644 index 01aafee..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/scrollbar-background.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static/icon/sunken-panel-border.png b/twisted/tailwindcsstheme/static/icon/sunken-panel-border.png deleted file mode 100644 index 1a24b13..0000000 Binary files a/twisted/tailwindcsstheme/static/icon/sunken-panel-border.png and /dev/null differ diff --git a/twisted/tailwindcsstheme/static_src/src/styles.css b/twisted/tailwindcsstheme/static_src/src/styles.css index c9f1952..88ff3c0 100644 --- a/twisted/tailwindcsstheme/static_src/src/styles.css +++ b/twisted/tailwindcsstheme/static_src/src/styles.css @@ -21,14 +21,6 @@ font-display: swap; } -@font-face { - font-family: "Piedra"; - src: url("/static/fonts/Piedra-Regular.ttf") format("truetype"); - font-weight: 100; - font-style: normal; - font-display: swap; -} - :root { /* Accent: primary buttons, links, focus ring, active states */ --color-accent: var(--color-red-500); @@ -67,15 +59,10 @@ --color-twisted-200: #e0a7ff; --color-twisted-400: #b96cff; --color-twisted-600: #8c3ed1; - --color-twisted-800: #61268f; - --color-twisted-999: #3a1750; - --color-absolute-white: #ffffff; - --color-fair-gray: #b8adbf; --color-dark-black: #120817; --color-surface: #2e153c; - --color-dark: #1b0d25; } @theme { @@ -94,9 +81,6 @@ --color-card: #1b0d25; --color-card-foreground: #faf7ff; - --color-popover: #48245c; - --color-popover-foreground: #ffffff; - --color-primary: #b96cff; --color-primary-foreground: #190b24; @@ -110,10 +94,8 @@ --color-muted-foreground: #c9b8d3; --color-subtle: #806a8d; - --color-subtle-foreground: #e2d8e8; --color-border: #654078; - --color-input: #75488a; --color-ring: #c27aff; --color-success: #4ade80; @@ -124,6 +106,17 @@ --color-destructive: #ff6b7a; --color-destructive-foreground: #2a080d; + + /* "OS" palette — must live in @theme for Tailwind to generate its utilities */ + --color-twisted-200: #e0a7ff; + --color-twisted-400: #b96cff; + --color-twisted-600: #8c3ed1; + --color-twisted-800: #61268f; + --color-twisted-999: #3a1750; + + --color-absolute-white: #ffffff; + --color-dark-black: #120817; + --color-surface: #2e153c; } button { @@ -162,33 +155,6 @@ button { image-rendering: pixelated; } -.window .maximized { - position: fixed !important; - inset: 0 0 32px 0 !important; - width: auto !important; - height: auto !important; - min-width: 0 !important; - min-height: 0 !important; - margin: 0 !important; - transform: none !important; -} - -progress { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - display: block; - height: 100%; - border: none; - box-sizing: border-box; - background-color: var(--color-primary); -} - -progress::-webkit-progress-bar { - background-color: var(--color-primary); - height: 100%; -} - ::-webkit-scrollbar { width: 10px; height: 10px; @@ -212,13 +178,6 @@ progress::-webkit-progress-bar { background: #1e072f; } -@utility no-scrollbar { - @apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden; -} - -::-webkit-scrollbar-corner { - background: #1e072f; -} .resize-handle { position: absolute; z-index: 10; diff --git a/twisted/twisted_site/ari.py b/twisted/twisted_site/ari.py index 4456750..0bb8c7c 100644 --- a/twisted/twisted_site/ari.py +++ b/twisted/twisted_site/ari.py @@ -7,7 +7,7 @@ import requests from django.conf import settings -from .models import Journal, Project, ProjectShip +from .models import Journal, Project, ProjectShip, as_user if TYPE_CHECKING: from collections.abc import Iterable @@ -20,6 +20,13 @@ WEBHOOK_MAX_AGE_SECONDS = 5 * 60 +def is_configured() -> bool: + """Return whether outbound ARI submissions can be sent.""" + return bool(settings.DEBUG_REVIEW) or ( + bool(ARI_INGEST_ENDPOINT) and bool(ARI_SIGNING_SECRET) + ) + + def verify_webhook_signature(body: bytes, timestamp: str, delivery_id: str, signature: str) -> bool: """ Verifies an outbound delivery from Ari. @@ -74,9 +81,11 @@ def send_request( else: message_bytes = None headers = {"Authorization": f"Bearer {ARI_SIGNING_SECRET}"} + base_url = ARI_INGEST_ENDPOINT.rstrip("/") + url = f"{base_url}/{endpoint.lstrip('/')}" if endpoint != "" else base_url return requests.request( method, - ARI_INGEST_ENDPOINT + endpoint, + url, data=message_bytes, headers=headers, timeout=10, @@ -94,9 +103,9 @@ def send_ship(ship: ProjectShip) -> None: untracked_time += journal.reduced_minutes maker = { - "email": ship.project.user.email, # pyrefly: ignore[missing-attribute] - "name": ship.project.user.profile.slack_username, # pyrefly: ignore[missing-attribute] - "slack_id": ship.project.user.profile.slack_id, # pyrefly: ignore[missing-attribute] + "email": as_user(ship.project.user).email, + "name": as_user(ship.project.user).profile.slack_username, + "slack_id": as_user(ship.project.user).profile.slack_id, "program_hours": 0, } @@ -111,7 +120,7 @@ def send_ship(ship: ProjectShip) -> None: thumbnail_url = ship.project.screenshot_url - hackatime_projects = ship.project.hackatime_project_names # pyrefly: ignore[missing-attribute] + hackatime_projects = ship.project.hackatime_project_names meta = { "project_url": f"https://twisted.hackclub.com/dashboard/?project={ship.project.id}", @@ -124,8 +133,8 @@ def send_ship(ship: ProjectShip) -> None: content = f"# Journal type: {journal.get_type_display()}\n\n{journal.content}" # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] journals.append( { - "at": journal.created_at.isoformat(), # ty: ignore[unresolved-attribute] - "minutes": int(journal.reduced_minutes), # ty: ignore[invalid-argument-type] + "at": journal.created_at.isoformat(), + "minutes": journal.reduced_minutes, "text": content, "markdown": content, }, @@ -153,7 +162,7 @@ def send_ship(ship: ProjectShip) -> None: def get_project_status(project: Project) -> dict[str, Any]: # pyrefly: ignore[explicit-any] - r = send_request("GET", endpoint=f"/status?external_id=twisted-{project.id}") # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] + r = send_request("GET", endpoint=f"/status?external_id=twisted-{project.id}") _resp = r.content r.raise_for_status() status_data: dict[str, Any] = r.json() # pyrefly: ignore[explicit-any] diff --git a/twisted/twisted_site/hackatime.py b/twisted/twisted_site/hackatime.py index 3845452..1902865 100644 --- a/twisted/twisted_site/hackatime.py +++ b/twisted/twisted_site/hackatime.py @@ -35,7 +35,7 @@ def authhelper(access_token: str, headers: dict[str, str] | None = None) -> dict def me(access_token: str) -> MeResponse: """Returns information about the authenticated user.""" resp = requests.get( - HACKATIME_ROOT_URL + "/api/v1/authenticated/me", + f"{HACKATIME_ROOT_URL}/api/v1/authenticated/me", headers=authhelper(access_token), timeout=10, ) @@ -67,7 +67,7 @@ def projects( params["projects"] = ",".join(projects) resp = requests.get( - HACKATIME_ROOT_URL + "/api/v1/authenticated/projects", + f"{HACKATIME_ROOT_URL}/api/v1/authenticated/projects", params=params, headers=authhelper(access_token), timeout=10, diff --git a/twisted/twisted_site/hca.py b/twisted/twisted_site/hca.py index 38f35c1..177b12f 100644 --- a/twisted/twisted_site/hca.py +++ b/twisted/twisted_site/hca.py @@ -1,11 +1,38 @@ from dataclasses import dataclass -from typing import Literal +from typing import Literal, TypedDict, cast import requests HCA_BASE_URL = "https://auth.hackclub.com/api/v1" +class _AddressPayload(TypedDict, total=False): + id: str + first_name: str + last_name: str + line_1: str + line_2: str + city: str + state: str + postal_code: str + country: str + phone_number: str + primary: bool + + +class _IdentityPayload(TypedDict, total=False): + id: str + ysws_eligible: bool + verification_status: Literal["needs_submission", "pending", "verified", "ineligible"] + first_name: str + last_name: str + primary_email: str + slack_id: str + phone_number: str + birthday: str + addresses: list[_AddressPayload] + + @dataclass class Address: id: str @@ -25,7 +52,7 @@ class Address: class Identity: id: str ysws_eligible: bool - verification_status: Literal["needs_submission", "pending", "verified", "ineligible"] + verification_status: Literal["needs_submission", "pending", "verified", "ineligible"] | None first_name: str last_name: str primary_email: str @@ -46,13 +73,16 @@ def get_auth_headers(access_token: str, headers: dict[str, str] | None = None) - def get_user_data(access_token: str) -> Identity: headers = get_auth_headers(access_token) - r = requests.get(HCA_BASE_URL + "/me", headers=headers, timeout=10) + r = requests.get(f"{HCA_BASE_URL}/me", headers=headers, timeout=10) r.raise_for_status() - resp = r.json()["identity"] + resp = cast("_IdentityPayload", r.json()["identity"]) addresses: list[Address] = [] primary_address: Address | None = None - for address in resp.get("addresses", []): + addresses_payload: list[_AddressPayload] | None = resp.get("addresses") + if addresses_payload is None: + addresses_payload = [] + for address in addresses_payload: transformed_address = Address( id=address.get("id", "None"), first_name=address.get("first_name", "None"), @@ -64,20 +94,20 @@ def get_user_data(access_token: str) -> Identity: postal_code=address.get("postal_code", "None"), country=address.get("country", "None"), phone_number=address.get("phone_number", "None"), - primary=address.get("primary", "None"), + primary=address.get("primary", False), ) if transformed_address.primary: primary_address = transformed_address addresses.append(transformed_address) return Identity( - id=resp["id"], + id=resp.get("id", "None"), ysws_eligible=resp.get("ysws_eligible", False), - verification_status=resp.get("verification_status", None), + verification_status=resp.get("verification_status"), first_name=resp.get("first_name", "None"), last_name=resp.get("last_name", "None"), - primary_email=resp["primary_email"], - slack_id=resp["slack_id"], + primary_email=resp.get("primary_email", "None"), + slack_id=resp.get("slack_id", "None"), phone_number=resp.get("phone_number", "None"), birthday=resp.get("birthday", "0000-00-00"), addresses=addresses, diff --git a/twisted/twisted_site/migrations/0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more.py b/twisted/twisted_site/migrations/0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more.py new file mode 100644 index 0000000..6610577 --- /dev/null +++ b/twisted/twisted_site/migrations/0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more.py @@ -0,0 +1,176 @@ +# Generated by Django 6.0.7 on 2026-09-19 04:56 + +import django.contrib.postgres.constraints +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def migrate_pathways_to_groups(apps, schema_editor): + """Group existing Pathways into PathwayGroups, merging any that overlap in time.""" + Pathway = apps.get_model("twisted_site", "Pathway") + PathwayGroup = apps.get_model("twisted_site", "PathwayGroup") + + clusters = [] + for pathway in Pathway.objects.order_by("start"): + target = None + for cluster in clusters: + if pathway.start < cluster["end"] and pathway.end > cluster["start"]: + target = cluster + break + if target is None: + clusters.append({"start": pathway.start, "end": pathway.end, "pathways": [pathway]}) + continue + target["start"] = min(target["start"], pathway.start) + target["end"] = max(target["end"], pathway.end) + target["pathways"].append(pathway) + + # Widening a cluster above may make it overlap another cluster that was + # already closed off; keep merging until no two clusters overlap. + merged = True + while merged: + merged = False + for i, cluster_a in enumerate(clusters): + for cluster_b in clusters[i + 1 :]: + if cluster_a["start"] < cluster_b["end"] and cluster_a["end"] > cluster_b["start"]: + cluster_a["start"] = min(cluster_a["start"], cluster_b["start"]) + cluster_a["end"] = max(cluster_a["end"], cluster_b["end"]) + cluster_a["pathways"].extend(cluster_b["pathways"]) + clusters.remove(cluster_b) + merged = True + break + if merged: + break + + for cluster in clusters: + group = PathwayGroup.objects.create(start=cluster["start"], end=cluster["end"]) + for pathway in cluster["pathways"]: + pathway.group = group + pathway.save(update_fields=["group"]) + + +def backfill_group_names(apps, schema_editor): + PathwayGroup = apps.get_model("twisted_site", "PathwayGroup") + for group in PathwayGroup.objects.filter(name=""): + group.name = f"Group ({group.start.date()} - {group.end.date()})" + group.save(update_fields=["name"]) + + +class Migration(migrations.Migration): + replaces = [ + ("twisted_site", "0034_pathwaygroup"), + ("twisted_site", "0035_pathwaygroup_name"), + ("twisted_site", "0036_pathwaytimespent_golden_twists_and_more"), + ] + + dependencies = [ + ("twisted_site", "0033_project_hackatime_project_names"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PathwayGroup", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("start", models.DateTimeField()), + ("end", models.DateTimeField()), + ], + options={ + "constraints": [ + models.CheckConstraint( + condition=models.Q(("start__lt", models.F("end"))), + name="pathwaygroup_start_before_end", + ), + django.contrib.postgres.constraints.ExclusionConstraint( + expressions=[ + ( + models.Func( + models.F("start"), models.F("end"), function="tstzrange" + ), + "&&", + ), + ], + name="pathwaygroup_no_overlapping_ranges", + ), + ], + }, + ), + migrations.CreateModel( + name="PathwayTimeSpent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("minutes", models.IntegerField(default=0)), + ( + "pathway", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="twisted_site.pathway" + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "unique_together": {("pathway", "user")}, + }, + ), + migrations.AddField( + model_name="pathway", + name="group", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="pathways", + to="twisted_site.pathwaygroup", + ), + ), + migrations.RunPython(migrate_pathways_to_groups, migrations.RunPython.noop), + migrations.AlterField( + model_name="pathway", + name="group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="pathways", + to="twisted_site.pathwaygroup", + ), + ), + migrations.RemoveField( + model_name="pathway", + name="end", + ), + migrations.RemoveField( + model_name="pathway", + name="start", + ), + migrations.AddField( + model_name="pathwaygroup", + name="name", + field=models.CharField(default="", max_length=200), + preserve_default=False, + ), + migrations.RunPython(backfill_group_names, migrations.RunPython.noop), + migrations.AddField( + model_name="pathwaytimespent", + name="golden_twists", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="pathwaytimespent", + name="unlocked", + field=models.BooleanField(default=False), + ), + ] diff --git a/twisted/twisted_site/migrations/0034_profile_country_profile_country_cached_until.py b/twisted/twisted_site/migrations/0034_profile_country_profile_country_cached_until.py index 4e46c5e..3db71b3 100644 --- a/twisted/twisted_site/migrations/0034_profile_country_profile_country_cached_until.py +++ b/twisted/twisted_site/migrations/0034_profile_country_profile_country_cached_until.py @@ -4,20 +4,19 @@ class Migration(migrations.Migration): - dependencies = [ - ('twisted_site', '0033_project_hackatime_project_names'), + ("twisted_site", "0033_project_hackatime_project_names"), ] operations = [ migrations.AddField( - model_name='profile', - name='country', - field=models.CharField(blank=True, default='', max_length=20), + model_name="profile", + name="country", + field=models.CharField(blank=True, default="", max_length=20), ), migrations.AddField( - model_name='profile', - name='country_cached_until', + model_name="profile", + name="country_cached_until", field=models.DateTimeField(default=None, null=True), ), ] diff --git a/twisted/twisted_site/migrations/0035_remove_pathwaygroup.py b/twisted/twisted_site/migrations/0035_remove_pathwaygroup.py new file mode 100644 index 0000000..f454d4c --- /dev/null +++ b/twisted/twisted_site/migrations/0035_remove_pathwaygroup.py @@ -0,0 +1,46 @@ +from django.db import migrations, models + + +def copy_group_dates_to_pathway(apps, schema_editor): + Pathway = apps.get_model("twisted_site", "Pathway") + for pathway in Pathway.objects.select_related("group"): + pathway.start = pathway.group.start + pathway.end = pathway.group.end + pathway.save(update_fields=["start", "end"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("twisted_site", "0034_pathwaygroup_squashed_0036_pathwaytimespent_golden_twists_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="pathway", + name="start", + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name="pathway", + name="end", + field=models.DateTimeField(null=True), + ), + migrations.RunPython(copy_group_dates_to_pathway, migrations.RunPython.noop), + migrations.AlterField( + model_name="pathway", + name="start", + field=models.DateTimeField(), + ), + migrations.AlterField( + model_name="pathway", + name="end", + field=models.DateTimeField(), + ), + migrations.RemoveField( + model_name="pathway", + name="group", + ), + migrations.DeleteModel( + name="PathwayGroup", + ), + ] diff --git a/twisted/twisted_site/migrations/0036_merge_20260922_1058.py b/twisted/twisted_site/migrations/0036_merge_20260922_1058.py new file mode 100644 index 0000000..ab76a27 --- /dev/null +++ b/twisted/twisted_site/migrations/0036_merge_20260922_1058.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0.7 on 2026-09-22 10:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0034_profile_country_profile_country_cached_until'), + ('twisted_site', '0035_remove_pathwaygroup'), + ] + + operations = [ + ] diff --git a/twisted/twisted_site/migrations/0036_merge_20260922_1730.py b/twisted/twisted_site/migrations/0036_merge_20260922_1730.py new file mode 100644 index 0000000..3c68502 --- /dev/null +++ b/twisted/twisted_site/migrations/0036_merge_20260922_1730.py @@ -0,0 +1,12 @@ +# Generated by Django 6.0.7 on 2026-09-22 17:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("twisted_site", "0034_profile_country_profile_country_cached_until"), + ("twisted_site", "0035_remove_pathwaygroup"), + ] + + operations = [] diff --git a/twisted/twisted_site/migrations/0037_shopregion_shopitem_shopitemregionalpricing.py b/twisted/twisted_site/migrations/0037_shopregion_shopitem_shopitemregionalpricing.py new file mode 100644 index 0000000..31e2e0d --- /dev/null +++ b/twisted/twisted_site/migrations/0037_shopregion_shopitem_shopitemregionalpricing.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.7 on 2026-09-22 10:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0036_merge_20260922_1058'), + ] + + operations = [ + migrations.CreateModel( + name='ShopRegion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=200)), + ], + ), + migrations.CreateModel( + name='ShopItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('item_name', models.CharField(max_length=500)), + ('item_description', models.TextField()), + ('pathway', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shop', to='twisted_site.pathway')), + ], + ), + migrations.CreateModel( + name='ShopItemRegionalPricing', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('price', models.IntegerField()), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='prices', to='twisted_site.shopitem')), + ('region', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='prices', to='twisted_site.shopregion')), + ], + ), + ] diff --git a/twisted/twisted_site/migrations/0038_profile_region.py b/twisted/twisted_site/migrations/0038_profile_region.py new file mode 100644 index 0000000..69640f4 --- /dev/null +++ b/twisted/twisted_site/migrations/0038_profile_region.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.7 on 2026-09-23 01:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0037_shopregion_shopitem_shopitemregionalpricing'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='region', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='twisted_site.shopregion'), + ), + ] diff --git a/twisted/twisted_site/migrations/0039_merge_0036_merge_20260922_1730_0038_profile_region.py b/twisted/twisted_site/migrations/0039_merge_0036_merge_20260922_1730_0038_profile_region.py new file mode 100644 index 0000000..abf4af9 --- /dev/null +++ b/twisted/twisted_site/migrations/0039_merge_0036_merge_20260922_1730_0038_profile_region.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0.7 on 2026-09-23 16:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0036_merge_20260922_1730'), + ('twisted_site', '0038_profile_region'), + ] + + operations = [ + ] diff --git a/twisted/twisted_site/migrations/0040_shopitem_stock.py b/twisted/twisted_site/migrations/0040_shopitem_stock.py new file mode 100644 index 0000000..a48b9f9 --- /dev/null +++ b/twisted/twisted_site/migrations/0040_shopitem_stock.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-24 04:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0039_merge_0036_merge_20260922_1730_0038_profile_region'), + ] + + operations = [ + migrations.AddField( + model_name='shopitem', + name='stock', + field=models.IntegerField(default=999), + ), + ] diff --git a/twisted/twisted_site/migrations/0041_shopitem_image_url.py b/twisted/twisted_site/migrations/0041_shopitem_image_url.py new file mode 100644 index 0000000..62e2bd5 --- /dev/null +++ b/twisted/twisted_site/migrations/0041_shopitem_image_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-24 05:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0040_shopitem_stock'), + ] + + operations = [ + migrations.AddField( + model_name='shopitem', + name='image_url', + field=models.CharField(blank=True, default='', max_length=500), + ), + ] diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index b89e7f5..39f398b 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -1,18 +1,16 @@ -from typing import TYPE_CHECKING, Any, cast, override +from datetime import timedelta +from typing import Any, Protocol, cast, override from django.contrib.auth import get_user_model from django.contrib.auth.base_user import AbstractBaseUser from django.core.validators import MinValueValidator from django.db import models -from django.db.models import TextField +from django.db.models import QuerySet, Sum, TextField from django.utils import timezone -from requests import HTTPError +from requests import RequestException from . import hackatime, hca -if TYPE_CHECKING: - from datetime import datetime - User = get_user_model() #: Template context dictionaries mix value types by design (mirroring @@ -29,9 +27,9 @@ class UploadedFile(models.Model): @override def __str__(self) -> str: - return ( - f"{self.cdn_response['filename']} uploaded by {self.uploaded_by.profile.slack_username}" # pyrefly: ignore[missing-attribute] - ) + cdn_response = cast("dict[str, str]", self.cdn_response) + filename = cdn_response.get("name", "upload") + return f"{filename} uploaded by {as_user(self.uploaded_by).profile.slack_username}" # Create your models here. @@ -71,21 +69,27 @@ class Profile(models.Model): country = models.CharField(max_length=20, default="", blank=True) country_cached_until = models.DateTimeField(null=True, default=None) + region = models.ForeignKey("twisted_site.ShopRegion", on_delete=models.PROTECT, null=True, default=None) + @override def __str__(self) -> str: - return cast("str", self.user.username) # pyrefly: ignore[missing-attribute] # ty: ignore[unresolved-attribute] + return as_user(self.user).username def get_country(self) -> str: if self.country_cached_until is not None and self.country_cached_until > timezone.now(): - return str(self.country) + return self.country # ty: ignore[unsound-return-statement] try: - user_data = hca.get_user_data(self.hca_access_token) # ty: ignore[invalid-argument-type] - country = user_data.primary_address.country if user_data.primary_address else "Unknown" - except HTTPError: + user_data = hca.get_user_data(self.hca_access_token) + country = ( + user_data.primary_address.country + if user_data.primary_address is not None + else "Unknown" + ) + except RequestException: country = "Unknown" - self.country = country # ty: ignore[invalid-assignment] - self.country_cached_until = timezone.now() + timezone.timedelta(hours=3) + self.country = country + self.country_cached_until = timezone.now() + timedelta(hours=3) self.save() return country @@ -93,13 +97,13 @@ def get_country(self) -> str: def shipped_projects(self) -> list["Project"]: return [ project - for project in cast("list[Project]", self.user.projects.all()) # pyrefly: ignore[missing-attribute] + for project in as_user(self.user).projects.all() if project.is_shipped() ] def time_logged(self) -> int: time_logged = 0 - for project in cast("list[Project]", self.user.projects.all()): # pyrefly: ignore[missing-attribute] + for project in as_user(self.user).projects.all(): time_logged += project.time_logged() return time_logged @@ -138,6 +142,7 @@ def __str__(self) -> str: class Project(models.Model): + id: int # pyright: ignore[reportUninitializedInstanceVariable] user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="projects") project_name = models.CharField(max_length=50) @@ -161,7 +166,7 @@ def get_hackatime_projects(self) -> list[hackatime.HackatimeProject]: names = cast("list[str]", self.hackatime_project_names) if len(names) == 0: return [] - projects = hackatime.projects(self.user.profile.hackatime_access_token) # pyrefly: ignore[missing-attribute] + projects = hackatime.projects(as_user(self.user).profile.hackatime_access_token) return [project for project in projects if project.name in names] def time_logged(self, *, include_all_minutes: bool = False) -> int: @@ -218,6 +223,7 @@ def is_approved(self) -> bool: class Journal(models.Model): + id: int # pyright: ignore[reportUninitializedInstanceVariable] project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="journals") type = models.CharField(max_length=100, choices=JOURNAL_TYPES) @@ -269,11 +275,11 @@ def __str__(self) -> str: class Pathway(models.Model): - start = models.DateTimeField() - end = models.DateTimeField() - + id: int # pyright: ignore[reportUninitializedInstanceVariable] name = models.CharField(max_length=200) min_mins = models.IntegerField(default=300) + start = models.DateTimeField() + end = models.DateTimeField() created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -282,128 +288,117 @@ class Pathway(models.Model): def __str__(self) -> str: return cast("str", self.name) # pyrefly: ignore[redundant-cast] + def get_unspent_mins(self, user: AbstractBaseUser) -> float: + total_spent = as_user(user).profile.time_logged() + spent_on_pathways = ( + PathwayTimeSpent.objects.filter(user=user).aggregate(total=Sum("minutes"))["total"] or 0 + ) + return total_spent - spent_on_pathways + def ended(self) -> bool: - return timezone.now() > cast("datetime", self.end) # pyrefly: ignore[redundant-cast] + return timezone.now() > self.end def didnt_start(self) -> bool: - return cast("datetime", self.start) > timezone.now() # pyrefly: ignore[redundant-cast] + return self.start > timezone.now() # ty: ignore[unsound-return-statement] def in_progress(self) -> bool: return not self.ended() and not self.didnt_start() - def status(self) -> str | None: - if self.ended(): - return "ended" - if self.didnt_start(): - return "awaiting" - if self.in_progress(): - return "in progress" - return None - def mins_spent(self, user: AbstractBaseUser) -> int: - pathways = Pathway.objects.order_by("start").values("id", "start", "end", "min_mins") - if not pathways.exists(): - return 0 + time_spent = PathwayTimeSpent.objects.filter(pathway=self, user=user).first() + return time_spent.minutes if time_spent is not None else 0 # ty: ignore[unsound-return-statement] - pathway_totals: dict[int, int] = {p["id"]: 0 for p in pathways} + def mins_spent_per_participant(self) -> dict[int, int]: + """ + Calculates the minutes spent on this specific pathway for all participants. + + Returns: + dict: {user_id: mins_spent} - journals = ( - Journal.objects.filter(project__user=user) - .order_by("created_at") - .values_list("created_at", "reduced_minutes") + """ + return dict( + PathwayTimeSpent.objects.filter(pathway=self).values_list("user_id", "minutes"), ) - for j_created, j_mins in journals: - mins_remaining = cast("int", j_mins) - for pathway in pathways: - if mins_remaining <= 0: - break + def qualified_participants(self) -> list[AbstractBaseUser]: + per_part = self.mins_spent_per_participant() + qualified: list[AbstractBaseUser] = [] + for userid, mins in per_part.items(): + if mins >= self.min_mins: + qualified.append(User.objects.get(id=userid)) + return qualified - # Check if journal falls within the pathway window - if pathway["start"] > j_created or pathway["end"] < j_created: - continue - p_id = cast("int", pathway["id"]) - mins_completed = pathway_totals.get(p_id, 0) - mins_required = cast("int", pathway["min_mins"]) +class PathwayTimeSpent(models.Model): + pathway_id: int # pyright: ignore[reportUninitializedInstanceVariable] + pathway = models.ForeignKey("twisted_site.Pathway", on_delete=models.CASCADE) + user = models.ForeignKey(to=User, on_delete=models.CASCADE) - if mins_completed >= mins_required: - continue + unlocked = models.BooleanField(default=False) + minutes = models.IntegerField(default=0) + golden_twists = models.IntegerField(default=0) - mins_needed = mins_required - mins_completed - mins_donated = min(mins_remaining, mins_needed) + class Meta: # meta :loll: + """Meta class for the PathwayTimeSpent model.""" - mins_remaining -= mins_donated - pathway_totals[p_id] = mins_completed + mins_donated + unique_together = ("pathway", "user") - return pathway_totals[self.id] # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] + @override + def __str__(self) -> str: + return f"{self.user} - {self.pathway}" - def mins_spent_per_participant(self) -> dict[int, int]: - """ - Calculates the minutes spent on this specific pathway for all participants. - Returns: - dict: {user_id: mins_spent} +class ShopRegion(models.Model): + id: int # pyright: ignore[reportUninitializedInstanceVariable] - """ - # Fetch all pathways to accurately model the sequential time donation - pathways = list(Pathway.objects.order_by("start").values("id", "start", "end", "min_mins")) - if len(pathways) == 0: - return {} - - # Fetch journals from all users that fit within this pathway's active time frame - journals = ( - Journal.objects.filter( - created_at__gte=self.start, - created_at__lte=self.end, - reduced_minutes__gt=0, - ) - .order_by("project__user_id", "created_at") - .values_list("project__user_id", "created_at", "reduced_minutes") - ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - user_pathway_totals: dict[int, dict[int, int]] = {} + name = models.CharField(max_length=200) - for user_id, j_created, j_mins in journals: - if user_id not in user_pathway_totals: - user_pathway_totals[user_id] = {p["id"]: 0 for p in pathways} + @override + def __str__(self) -> str: + return self.name # ty: ignore[unsound-return-statement] - pathway_totals = user_pathway_totals[user_id] - mins_remaining = cast("int", j_mins) - for pathway in pathways: - if mins_remaining <= 0: - break +class ShopItemRegionalPricing(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - if pathway["start"] > j_created or pathway["end"] < j_created: - continue + region = models.ForeignKey( + "twisted_site.ShopRegion", + related_name="prices", + on_delete=models.PROTECT, + ) + item = models.ForeignKey( + "twisted_site.ShopItem", + related_name="prices", + on_delete=models.PROTECT, + ) - p_id = cast("int", pathway["id"]) - mins_completed = pathway_totals[p_id] - mins_required = cast("int", pathway["min_mins"]) + price = models.IntegerField() - if mins_completed >= mins_required: - continue + @override + def __str__(self) -> str: + return f"{self.item} in {self.region}: {self.price}" - mins_needed = mins_required - mins_completed - mins_donated = min(mins_remaining, mins_needed) - mins_remaining -= mins_donated - pathway_totals[p_id] += mins_donated +class ShopItem(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - # Extract only this pathway's result for each participant - return { - user_id: totals.get(self.id, 0) # ty:ignore[unresolved-attribute] # pyright: ignore[reportAttributeAccessIssue] - for user_id, totals in user_pathway_totals.items() - } + pathway = models.ForeignKey("twisted_site.Pathway", on_delete=models.PROTECT, related_name="shop") - def qualified_participants(self) -> list[AbstractBaseUser]: - per_part = self.mins_spent_per_participant() - qualified: list[AbstractBaseUser] = [] - for userid, mins in per_part.items(): - if mins >= self.min_mins: - qualified.append(User.objects.get(id=userid)) - return qualified + item_name = models.CharField(max_length=500) + item_description = models.TextField() + + stock = models.IntegerField(default=999) + + image_url = models.CharField(max_length=500, blank=True, default="") + + @override + def __str__(self) -> str: + return self.item_name # ty: ignore[unsound-return-statement] class AuditLog(models.Model): @@ -417,4 +412,27 @@ class AuditLog(models.Model): @override def __str__(self) -> str: - return f"Audit log for {self.user.profile.slack_username}. PII: {self.pii}" # pyrefly: ignore[missing-attribute] + return f"Audit log for {as_user(self.user).profile.slack_username}. PII: {self.pii}" + + +class _ProjectsManager(Protocol): + def all(self) -> QuerySet[Project]: ... + + +class UserWithProfile(Protocol): + """ + The built-in user model plus the relations this project adds to it. + + Reverse relations (and concrete fields like `username` on `get_user_model()`) + cannot be resolved by pyrefly/ty/pyright without the django-stubs mypy plugin, + so access goes through this protocol instead of per-tool ignore comments. + """ + + profile: Profile + projects: _ProjectsManager + username: str + email: str + + +def as_user(user: object) -> UserWithProfile: + return cast("UserWithProfile", user) diff --git a/twisted/twisted_site/static/fonts/Piedra-Regular.ttf b/twisted/twisted_site/static/fonts/Piedra-Regular.ttf deleted file mode 100644 index c3c75e4..0000000 Binary files a/twisted/twisted_site/static/fonts/Piedra-Regular.ttf and /dev/null differ diff --git a/twisted/twisted_site/static/fonts/VT323-Regular.ttf b/twisted/twisted_site/static/fonts/VT323-Regular.ttf deleted file mode 100644 index 6aec599..0000000 Binary files a/twisted/twisted_site/static/fonts/VT323-Regular.ttf and /dev/null differ diff --git a/twisted/twisted_site/static/fonts/ms_sans_serif.woff b/twisted/twisted_site/static/fonts/ms_sans_serif.woff deleted file mode 100644 index a8df7f1..0000000 Binary files a/twisted/twisted_site/static/fonts/ms_sans_serif.woff and /dev/null differ diff --git a/twisted/twisted_site/static/fonts/ms_sans_serif.woff2 b/twisted/twisted_site/static/fonts/ms_sans_serif.woff2 deleted file mode 100644 index 83ea806..0000000 Binary files a/twisted/twisted_site/static/fonts/ms_sans_serif.woff2 and /dev/null differ diff --git a/twisted/twisted_site/static/fonts/ms_sans_serif_bold.woff b/twisted/twisted_site/static/fonts/ms_sans_serif_bold.woff deleted file mode 100644 index 44064b3..0000000 Binary files a/twisted/twisted_site/static/fonts/ms_sans_serif_bold.woff and /dev/null differ diff --git a/twisted/twisted_site/static/fonts/ms_sans_serif_bold.woff2 b/twisted/twisted_site/static/fonts/ms_sans_serif_bold.woff2 deleted file mode 100644 index 610c091..0000000 Binary files a/twisted/twisted_site/static/fonts/ms_sans_serif_bold.woff2 and /dev/null differ diff --git a/twisted/twisted_site/static/images/round-square.jpg b/twisted/twisted_site/static/images/round-square.jpg deleted file mode 100644 index e72b362..0000000 Binary files a/twisted/twisted_site/static/images/round-square.jpg and /dev/null differ diff --git a/twisted/twisted_site/static/images/twisted_t.png b/twisted/twisted_site/static/images/twisted_t.png new file mode 100644 index 0000000..26f0575 Binary files /dev/null and b/twisted/twisted_site/static/images/twisted_t.png differ diff --git a/twisted/twisted_site/templates/admin/dashboard.html b/twisted/twisted_site/templates/admin/dashboard.html index 2a3ed88..e674657 100644 --- a/twisted/twisted_site/templates/admin/dashboard.html +++ b/twisted/twisted_site/templates/admin/dashboard.html @@ -1,6 +1,20 @@ +

Welcome back, {{ user.profile.slack_username }}!

@@ -49,12 +63,15 @@

Welcome back, {{ user.profile.slack_usern google.charts.setOnLoadCallback(drawChart); function drawChart() { - var data = google.visualization.arrayToDataTable({{ hours_logged_chart|safe }}); + var data = hoursDateData({{ hours_logged_chart|safe }}); var options = { vAxis: { minValue: 0 }, + hAxis: { + format: 'EEE, d MMM' + }, animation: { duration: 250, startup: true @@ -165,12 +182,15 @@

Welcome back, {{ user.profile.slack_usern google.charts.setOnLoadCallback(drawChart); function drawChart() { - var data = google.visualization.arrayToDataTable({{ hours_shipped_chart|safe }}); + var data = hoursDateData({{ hours_shipped_chart|safe }}); var options = { vAxis: { minValue: 0 }, + hAxis: { + format: 'EEE, d MMM' + }, animation: { duration: 250, startup: true diff --git a/twisted/twisted_site/templates/admin/pathways/create.html b/twisted/twisted_site/templates/admin/pathways/create.html index d5cbbc5..f973446 100644 --- a/twisted/twisted_site/templates/admin/pathways/create.html +++ b/twisted/twisted_site/templates/admin/pathways/create.html @@ -24,7 +24,7 @@

Create Pathway

Start
- +
@@ -34,7 +34,7 @@

Create Pathway

End
- +
diff --git a/twisted/twisted_site/templates/admin/pathways/detail.html b/twisted/twisted_site/templates/admin/pathways/detail.html index 9ec294a..4c98121 100644 --- a/twisted/twisted_site/templates/admin/pathways/detail.html +++ b/twisted/twisted_site/templates/admin/pathways/detail.html @@ -1,5 +1,77 @@ {% load time_filters %} +
Admin @@ -96,5 +168,161 @@

No participants yet

{% endif %}
+ +
+

Shop listings

+ {% if shop_items %} + + Create new listing + + + + Image + Name + Description + Stock + Last updated + + + +
+ {% for item in shop_items %} + + + {% if item.image_url %} + + {% endif %} + + + {{ item.item_name }} + + {{ item.item_description|truncatechars:20 }} + + {{ item.stock }} + + + {{ item.updated_at }} + + + +
+
+
+ +

+ Loading... +

+

+ Kaboom +

+
+
+
+ + {% endfor %} + +
+
+ {% else %} + +

No shop items yet

+

Create a new shop item.

+ Create new listing +
+ {% endif %} +
+
+ +
+
+ New shop listing for {{ pathway.name }} +
+ + × + +
+
+
+ {% csrf_token %} + +
+ +
+
+ +
+
+ +
+
+ Item image + + +

or paste an image anywhere in this dialog

+

+ +
+
+ Regions and availability +

Leave price blank for no availability

+ + + Region + Price + + + {% for region in shop_regions %} + + + {{ region.name }} + + + + + + {% empty %} + + + + + No regions found :( + + + + + {% endfor %} + + +
+ + Create! + +
+
+
+
+
+ +
diff --git a/twisted/twisted_site/templates/admin/pathways/list.html b/twisted/twisted_site/templates/admin/pathways/list.html index 437dee2..f42094a 100644 --- a/twisted/twisted_site/templates/admin/pathways/list.html +++ b/twisted/twisted_site/templates/admin/pathways/list.html @@ -1,7 +1,7 @@
-
-

Pathways

+ + diff --git a/twisted/twisted_site/templates/admin/shop.html b/twisted/twisted_site/templates/admin/shop.html index 607ee0b..057b870 100644 --- a/twisted/twisted_site/templates/admin/shop.html +++ b/twisted/twisted_site/templates/admin/shop.html @@ -1,8 +1,7 @@ - - Primary Button - - - Default Button - + + + Manage regions + + diff --git a/twisted/twisted_site/templates/admin/shop_regions.html b/twisted/twisted_site/templates/admin/shop_regions.html new file mode 100644 index 0000000..b3ecfcb --- /dev/null +++ b/twisted/twisted_site/templates/admin/shop_regions.html @@ -0,0 +1,88 @@ + +
+ + Admin + Shop + + Regions + + +

Shop Regions

+ + +

New region

+
+ {% csrf_token %} + +
+ +
+ + Create region + +
+
+ +
+ {% if regions %} + + + + + Name + + + + + {% for region in regions %} + + +
+ {% csrf_token %} + + + + + Save + + + + + + + + Delete + + +
+

+ Are you sure you want to delete "{{ region.name }}"? This cannot + be undone. +

+
+
+ {% csrf_token %} + + + + Delete region + +
+
+
+
+ + + {% endfor %} + +
+
+ {% else %} + +

No regions found :(

+

Create one above to get started!

+
+ {% endif %} +
+
+
diff --git a/twisted/twisted_site/templates/admin/user.html b/twisted/twisted_site/templates/admin/user.html index d3c8f6b..9586547 100644 --- a/twisted/twisted_site/templates/admin/user.html +++ b/twisted/twisted_site/templates/admin/user.html @@ -186,7 +186,7 @@

Admin True False - + Save

@@ -212,7 +212,7 @@

Not an admin!

- + + {% block body %} meow :3 {% endblock body %} {% endblock body %} diff --git a/twisted/twisted_site/templates/client/projects/ship.html b/twisted/twisted_site/templates/client/projects/ship.html index 58f93a5..803f416 100644 --- a/twisted/twisted_site/templates/client/projects/ship.html +++ b/twisted/twisted_site/templates/client/projects/ship.html @@ -25,7 +25,7 @@
Did yk 90% of rejections could have taken 5 minutes to fix?
Our reviewers will manually check your project.
- {% if project.type == "software" %} + {% if project.project_type == "software" %} Make sure your project works before shipping it. {% else %} Make sure your design is shipped and looks like it will work. diff --git a/twisted/twisted_site/templates/client/shop.html b/twisted/twisted_site/templates/client/shop.html new file mode 100644 index 0000000..dac31b7 --- /dev/null +++ b/twisted/twisted_site/templates/client/shop.html @@ -0,0 +1,135 @@ +{% extends "client/osbase.html" %} +{% load static time_filters %} +{% block body %} +
+
+
+ Pathway +
+ + +
+
+
+
+ Your Region +
+ {% csrf_token %} + + + +
+
+
+
+
+ {% if user.profile.region %} + {% if pathway_timespent %} +
+ {{ pathway_timespent.golden_twists }} golden twists for this pathway +
+ {% endif %} + {% if shop_items %} +
+ {% for listing in shop_items %} +
+
+ {% if listing.item.image_url %} + 📦 + {% else %} +

+ 📦 +

+ {% endif %} +
+
+
+ {{ listing.item.item_name }} +
+
+ {{ listing.item.item_description|truncatechars:30 }} +
+
+ {{ listing.price.price }} twist{{ listing.price.price|pluralize }} +
+
+
+ {% endfor %} +
+ {% else %} +
+
+
No shop items here yet :c
+
Check back later or select a different pathway
+
+
+ {% endif %} + {% else %} +
+
+
+
+ ^^^^ +
+
+ select a region here! +
+
+
+
+
+
No region selected
+
Please select a region to get started.
+
+
+
+ {% endif %} +
+
+ +{% endblock body %} diff --git a/twisted/twisted_site/templates/cotton/base.html b/twisted/twisted_site/templates/cotton/base.html index d1dea53..50a0e86 100644 --- a/twisted/twisted_site/templates/cotton/base.html +++ b/twisted/twisted_site/templates/cotton/base.html @@ -54,24 +54,15 @@ {% if messages %} - {% for message in messages %} -
- -
- {% endfor %} +
+ {% for message in messages %} + + {{ message }} + + {% endfor %} +
{% endif %} diff --git a/twisted/twisted_site/templates/cotton/button.html b/twisted/twisted_site/templates/cotton/button.html deleted file mode 100644 index 8c4cbb6..0000000 --- a/twisted/twisted_site/templates/cotton/button.html +++ /dev/null @@ -1,4 +0,0 @@ - diff --git a/twisted/twisted_site/templates/cotton/client/pathway_card.html b/twisted/twisted_site/templates/cotton/client/pathway_card.html new file mode 100644 index 0000000..9fd4183 --- /dev/null +++ b/twisted/twisted_site/templates/cotton/client/pathway_card.html @@ -0,0 +1,64 @@ +{% load static time_filters humanize humanizelib maths mathfilters %} +
+
+
+

+ {{ pathway_info.pathway.name|default:"none! please dm @kavyansh." }} +

+
+ {% if pathway_info.pathway.in_progress or pathway_info.pathway.ended %} + Started + {% endif %} + + {{ pathway_info.pathway.start|naturaltime }} + + • + Ends + + {{ pathway_info.pathway.end|naturaltime }} + +
+
+
+
+ +
+ {% if not pathway_info.pathway.didnt_start %} +
+ {% if pathway_info.time_spent.unlocked %} +
+
unlocked
+
spent {{ pathway_info.time_spent.minutes|minutes_to_hours_minutes }}
+
+ {% elif unspent_mins >= pathway_info.pathway.min_mins %} +
+
+ {% csrf_token %} + +
+
+ {% else %} +
+ +
+ {% endif %} +
+ {% endif %} +
+
+
diff --git a/twisted/twisted_site/templates/cotton/homepage_taskbar.html b/twisted/twisted_site/templates/cotton/homepage_taskbar.html index cdf608d..7f4ad7b 100644 --- a/twisted/twisted_site/templates/cotton/homepage_taskbar.html +++ b/twisted/twisted_site/templates/cotton/homepage_taskbar.html @@ -3,7 +3,7 @@