diff --git a/.gitignore b/.gitignore index c3c1864..98a633c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,3 @@ twisted/*.sqlite3 .env twisted/.env -static/ diff --git a/.vscode/settings.json b/.vscode/settings.json index a382656..7c3845a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,7 @@ ".venv": true, "**/__pycache__": true }, - "djangoCotton.templatePaths": ["templates/cotton", "twisted/*/templates/cotton"] + "djangoCotton.templatePaths": [ + "twisted/templates/cotton", "twisted/*/templates/cotton", ".venv/lib/*/site-packages/django_cotton_ui/templates/cotton" + ] } diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 6c8b8c8..0000000 --- a/TODO.md +++ /dev/null @@ -1,13 +0,0 @@ -# Kavyansh -- [x] implement hackatime auth -- [x] add project creation -- [x] add hackatime helper functions -- [x] implement hackatime for projects -- [ ] add journal creation (backend) -- [ ] add journal editing -- [ ] implement shipping -- [ ] implement pathways -- [ ] implement images in journals -- [ ] implement admin dash -- [ ] implement reviewer dash -- [ ] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index abed9d8..49fdf52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,16 @@ dependencies = [ "psycopg[binary]>=3.3.4", "python-dotenv>=1.2.2", "requests>=2.34.2", - "slack-sdk>=3.43.0", + "slack-bolt>=1.30.0", "tzdata>=2026.3", "urllib3>=2.7.0", "whitenoise>=6.12.0", + "boto3>=1.43.66", + "django-cotton-ui>=0.3.2", + "django-google-charts>=0.1.1", + "django-qsstats-magic>=1.1.0", + "django-htmx>=1.29.0", + "django-extensions>=4.1", + "django-mathfilters>=1.0.0", + "django-humanize>=0.1.2", ] diff --git a/slack_bot/slack.py b/slack_bot/slack.py new file mode 100644 index 0000000..e69de29 diff --git a/twisted/.env.example b/twisted/.env.example index 5acfbb2..624d072 100644 --- a/twisted/.env.example +++ b/twisted/.env.example @@ -1,3 +1,8 @@ +SECRET_KEY= +LOGIN_ENABLED=true # possible options: 'true,false,maybe' + +DEBUG=true + POSTGRES_DB = django POSTGRES_USER = django POSTGRES_PASSWORD = django @@ -13,10 +18,21 @@ HACKATIME_CLIENT_SECRET = HACKATIME_REDIRECT_URI = https://localhost:8000/auth/hackatime_callback SLACK_TOKEN = xoxb-*** +SLACK_SIGNING_SECRET = +SLACK_APP_TOKEN = xapp-*** +SLACK_MODE = https ALLOWED_HOSTS = 127.0.0.1, localhost CSRF_TRUSTED_ORIGINS = http://127.0.0.1:8000, http://localhost:8000 -HACKCLUB_CDN_API_KEY = sk_cdn_*** +R2_ENDPOINT = https://.r2.cloudflarestorage.com +R2_ACCESS_KEY = +R2_SECRET_KEY = +R2_BUCKET = +R2_PUBLIC_URL = +R2_TOKEN = -LOGIN_ENABLED=true \ No newline at end of file +DEBUG_REVIEW=true +ARI_INGEST_ENDPOINT= +ARI_SIGNING_SECRET= +ARI_WEBHOOK_SECRET= \ No newline at end of file diff --git a/twisted/Dockerfile b/twisted/Dockerfile index c8cec61..1d607c5 100644 --- a/twisted/Dockerfile +++ b/twisted/Dockerfile @@ -1,5 +1,5 @@ ### Build ### -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS development +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS build ENV DEBIAN_FRONTEND=noninteractive diff --git a/twisted/common/templates/base.html b/twisted/common/templates/base.html index 380ba3c..aeea556 100644 --- a/twisted/common/templates/base.html +++ b/twisted/common/templates/base.html @@ -1,12 +1,19 @@ -{% load static tailwind_tags static %} +{% load tailwind_tags static %} {% tailwind_css %} {% block head %}{% endblock head %} + + Twisted - + {% block body %} meow :3 {% endblock body %} + \ No newline at end of file diff --git a/twisted/docker-compose.yml b/twisted/docker-compose.yml index 89a9b89..120300c 100644 --- a/twisted/docker-compose.yml +++ b/twisted/docker-compose.yml @@ -11,6 +11,10 @@ services: db: image: postgres:16 restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s diff --git a/twisted/entrypoint.sh b/twisted/entrypoint.sh index 99abb81..8fddaab 100644 --- a/twisted/entrypoint.sh +++ b/twisted/entrypoint.sh @@ -1,4 +1,9 @@ #!/bin/bash uv run manage.py migrate --noinput -uv run gunicorn -w 3 mysite.wsgi:application --bind 0.0.0.0:8000 \ No newline at end of file +uv run gunicorn -w 3 mysite.wsgi:application \ + --bind 0.0.0.0:8000 \ + --threads=4 \ + --worker-class=gthread \ + --log-level info \ + --capture-output diff --git a/twisted/mysite/middleware.py b/twisted/mysite/middleware.py new file mode 100644 index 0000000..19e33d5 --- /dev/null +++ b/twisted/mysite/middleware.py @@ -0,0 +1,19 @@ +import zoneinfo +from django.utils import timezone + +class TimezoneMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + # get django_timezone from cookie + tzname = request.COOKIES.get("django_timezone") + if tzname: + timezone.activate(zoneinfo.ZoneInfo(tzname)) + else: + timezone.deactivate() + except Exception as e: + timezone.deactivate() + + return self.get_response(request) diff --git a/twisted/mysite/settings.py b/twisted/mysite/settings.py index 662000b..6933734 100644 --- a/twisted/mysite/settings.py +++ b/twisted/mysite/settings.py @@ -24,14 +24,24 @@ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-&3p#whs%8@!e1=fl$!wk^y6^e@z+714@714xd$=0ghmfyk43bn' +SECRET_KEY = os.environ['SECRET_KEY'] # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.environ.get('DEBUG', 'false').lower() in ['true', 'on', '1'] +DEBUG_REVIEW = os.environ.get('DEBUG_REVIEW', str(DEBUG)).lower() in ['true', 'on', '1'] USE_X_FORWARDED_HOST = True SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +# Behind the TLS-terminating proxy above, cookies should never travel over plain +# HTTP once we're not in local dev. +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG +SECURE_SSL_REDIRECT = not DEBUG +SECURE_HSTS_SECONDS = 0 if DEBUG else 60 * 60 * 24 * 365 +SECURE_HSTS_INCLUDE_SUBDOMAINS = not DEBUG +SECURE_HSTS_PRELOAD = not DEBUG + X_FRAME_OPTIONS = 'SAMEORIGIN' allowed_hosts_raw = os.getenv("ALLOWED_HOSTS", "127.0.0.1,localhost") @@ -45,13 +55,13 @@ TAILWIND_APP_NAME = 'tailwindcsstheme' INSTALLED_APPS = [ - 'jazzmin', - 'django.contrib.admin', + # 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.contrib.humanize', # Your apps 'common', @@ -59,8 +69,13 @@ # 3rd party apps 'django_cotton', + 'django_cotton_ui', 'tailwind', TAILWIND_APP_NAME, + 'django_htmx', + 'django_extensions', + 'mathfilters', + 'django_humanize' ] if DEBUG: @@ -76,6 +91,8 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'mysite.middleware.TimezoneMiddleware', + "django_htmx.middleware.HtmxMiddleware", ] if DEBUG: @@ -146,6 +163,7 @@ TIME_ZONE = 'UTC' USE_I18N = True +USE_L10N = True USE_TZ = True @@ -156,4 +174,31 @@ STATIC_URL = 'static/' STATIC_ROOT = BASE_DIR / 'static' CSRF_COOKIE_HTTPONLY = False -NPM_BIN_PATH = os.environ.get("NPM_BIN_PATH", "npm") \ No newline at end of file +NPM_BIN_PATH = os.environ.get("NPM_BIN_PATH", "npm") + +# Django Messages Framework +MESSAGE_STORAGE = "django.contrib.messages.storage.cookie.CookieStorage" + + +# Logging +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, # Keeps Gunicorn's loggers active + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} + +# Ari (Review) +# https://ari.hackclub.com/docs/webhooks +ARI_INGEST_ENDPOINT = os.environ.get('ARI_INGEST_ENDPOINT') +ARI_SIGNING_SECRET = os.environ.get('ARI_SIGNING_SECRET') +# Separate from ARI_SIGNING_SECRET: signs deliveries Ari sends to us (Settings -> Webhooks), +# not requests we send to Ari. +ARI_WEBHOOK_SECRET = os.environ.get('ARI_WEBHOOK_SECRET') \ No newline at end of file diff --git a/twisted/mysite/urls.py b/twisted/mysite/urls.py index c408299..c5b2bf4 100644 --- a/twisted/mysite/urls.py +++ b/twisted/mysite/urls.py @@ -15,18 +15,15 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.contrib import admin from django.urls import include, path from django.conf import settings from django.conf.urls.static import static urlpatterns = [ - path("admin/", admin.site.urls), path("", include("twisted_site.urls")), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) if settings.DEBUG: - # Include django_browser_reload URLs only in DEBUG mode urlpatterns += [ path("__reload__/", include("django_browser_reload.urls")), ] diff --git a/twisted/tailwindcsstheme/static_src/package-lock.json b/twisted/tailwindcsstheme/static_src/package-lock.json index b4da7e8..55d9798 100644 --- a/twisted/tailwindcsstheme/static_src/package-lock.json +++ b/twisted/tailwindcsstheme/static_src/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/typography": "^0.5.20", "98.css": "^0.1.21", "concurrently": "^10.0.4", "cross-env": "^10.1.0", @@ -374,6 +375,33 @@ "tailwindcss": "4.3.3" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/98.css": { "version": "0.1.21", "resolved": "https://registry.npmjs.org/98.css/-/98.css-0.1.21.tgz", diff --git a/twisted/tailwindcsstheme/static_src/package.json b/twisted/tailwindcsstheme/static_src/package.json index dc89b19..1e44e6e 100644 --- a/twisted/tailwindcsstheme/static_src/package.json +++ b/twisted/tailwindcsstheme/static_src/package.json @@ -6,14 +6,15 @@ "start": "npm run dev", "build": "npm run build:clean && npm run build:tailwind", "build:clean": "rimraf ../static/css/dist", - "build:tailwind": "cross-env NODE_ENV=production postcss ./src/styles.css -o ../static/css/dist/styles.css --minify && postcss ./src/win98.css -o ../static/css/dist/win98.css --minify", - "dev": "concurrently \"cross-env NODE_ENV=development postcss ./src/styles.css -o ../static/css/dist/styles.css --watch\" \"cross-env NODE_ENV=development postcss ./src/win98.css -o ../static/css/dist/win98.css --watch\"" + "build:tailwind": "cross-env NODE_ENV=production postcss ./src/styles.css -o ../static/css/dist/styles.css --minify", + "dev": "cross-env NODE_ENV=development postcss ./src/styles.css -o ../static/css/dist/styles.css --watch" }, "keywords": [], "author": "", "license": "MIT", "devDependencies": { "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/typography": "^0.5.20", "98.css": "^0.1.21", "concurrently": "^10.0.4", "cross-env": "^10.1.0", @@ -24,4 +25,4 @@ "rimraf": "^6.1.3", "tailwindcss": "^4.3.0" } -} +} \ No newline at end of file diff --git a/twisted/tailwindcsstheme/static_src/src/cotton-ui.sources.css b/twisted/tailwindcsstheme/static_src/src/cotton-ui.sources.css new file mode 100644 index 0000000..955e8f3 --- /dev/null +++ b/twisted/tailwindcsstheme/static_src/src/cotton-ui.sources.css @@ -0,0 +1,188 @@ +/* Generated by `manage.py cotton_ui_sources`. Do not edit. + @import after `@import "tailwindcss"`; brand via :root / .dark after it. */ + +@custom-variant dark (&:where(.dark, .dark *)); + +/* Cotton UI tokens: accent palette, component tokens (radius, surfaces, shadows, + focus ring) and x-cloak. Brand by overriding --color-accent via :root / .dark. */ + +@theme { + --color-accent: var(--color-ink-900); + --color-accent-content: var(--color-ink-700); + --color-accent-foreground: var(--color-white); + + /* Component tokens declared in @theme (not just :root) so their named utilities + generate: text-muted, bg-surface, border-box-border, bg-input-bg, bg-accent-muted. + These are the light defaults; .dark overrides them in the @layer theme block below. */ + --color-muted: var(--color-ink-500); /* secondary text (text-muted): meta lines, descriptions, timestamps */ + --color-box-border: var(--color-ink-200); /* border for box-role surfaces: cards, dialogs, menus */ + --color-accent-muted: var(--color-ink-900); /* off-accent fill: :accent=false controls re-tone with the Ink control */ + --color-surface: color-mix(in oklab, var(--color-bg), white var(--surface-level)); /* raised surfaces, derived from --color-bg + --surface-level */ + --color-input-bg: transparent; /* form fields: border-defined in light; .dark lifts it */ + + /* Radius is split by role, not by size, and kept in its own namespace so it never + collides with Tailwind's --radius-md/-lg scale or your own rounded-* usage. + Declared in @theme so the `rounded-control` / `rounded-box` utilities are generated. */ + --radius-control: 0.375rem; /* form controls: inputs, segments → rounded-control */ + --radius-box: 0.5rem; /* structural: cards, dialogs, menus → rounded-box */ + --radius-button: var(--radius-control); /* buttons → rounded-button; defaults to the control radius, set to a large value for pill buttons */ + + /* Extra neutral/gray scales beyond Tailwind's five, so the theme builder's Gray base + can remap zinc to a warmer/cooler neutral. Only emitted when referenced (the theme + builder primes them); they add nothing to the bundle unless that component is used. */ + --color-taupe-50: oklch(98.6% 0.002 67.8); + --color-taupe-100: oklch(96% 0.002 17.2); + --color-taupe-200: oklch(92.2% 0.005 34.3); + --color-taupe-300: oklch(86.8% 0.007 39.5); + --color-taupe-400: oklch(71.4% 0.014 41.2); + --color-taupe-500: oklch(54.7% 0.021 43.1); + --color-taupe-600: oklch(43.8% 0.017 39.3); + --color-taupe-700: oklch(36.7% 0.016 35.7); + --color-taupe-800: oklch(26.8% 0.011 36.5); + --color-taupe-900: oklch(21.4% 0.009 43.1); + --color-taupe-950: oklch(14.7% 0.004 49.3); + + --color-mauve-50: oklch(98.5% 0 0); + --color-mauve-100: oklch(96% 0.003 325.6); + --color-mauve-200: oklch(92.2% 0.005 325.62); + --color-mauve-300: oklch(86.5% 0.012 325.68); + --color-mauve-400: oklch(71.1% 0.019 323.02); + --color-mauve-500: oklch(54.2% 0.034 322.5); + --color-mauve-600: oklch(43.5% 0.029 321.78); + --color-mauve-700: oklch(36.4% 0.029 323.89); + --color-mauve-800: oklch(26.3% 0.024 320.12); + --color-mauve-900: oklch(21.2% 0.019 322.12); + --color-mauve-950: oklch(14.5% 0.008 326); + + --color-mist-50: oklch(98.7% 0.002 197.1); + --color-mist-100: oklch(96.3% 0.002 197.1); + --color-mist-200: oklch(92.5% 0.005 214.3); + --color-mist-300: oklch(87.2% 0.007 219.6); + --color-mist-400: oklch(72.3% 0.014 214.4); + --color-mist-500: oklch(56% 0.021 213.5); + --color-mist-600: oklch(45% 0.017 213.2); + --color-mist-700: oklch(37.8% 0.015 216); + --color-mist-800: oklch(27.5% 0.011 216.9); + --color-mist-900: oklch(21.8% 0.008 223.9); + --color-mist-950: oklch(14.8% 0.004 228.8); + + --color-olive-50: oklch(98.8% 0.003 106.5); + --color-olive-100: oklch(96.6% 0.005 106.5); + --color-olive-200: oklch(93% 0.007 106.5); + --color-olive-300: oklch(88% 0.011 106.6); + --color-olive-400: oklch(73.7% 0.021 106.9); + --color-olive-500: oklch(58% 0.031 107.3); + --color-olive-600: oklch(46.6% 0.025 107.3); + --color-olive-700: oklch(39.4% 0.023 107.4); + --color-olive-800: oklch(28.6% 0.016 107.4); + --color-olive-900: oklch(22.8% 0.013 107.4); + --color-olive-950: oklch(15.3% 0.006 107.1); + + /* Neutral scale the components are authored against. Defaults to the zinc look, so the + precompiled (no-build / CDN) stylesheet needs zero config. Re-tone the whole UI by + overriding --color-ink-* (directly or via the theme builder) WITHOUT touching a + consumer's own zinc-* usage. Kept as var() refs so an app that re-themes zinc still + cascades through; the priming list forces both scales into the shipped CSS. */ + --color-ink-50: var(--color-zinc-50); + --color-ink-100: var(--color-zinc-100); + --color-ink-200: var(--color-zinc-200); + --color-ink-300: var(--color-zinc-300); + --color-ink-400: var(--color-zinc-400); + --color-ink-500: var(--color-zinc-500); + --color-ink-600: var(--color-zinc-600); + --color-ink-700: var(--color-zinc-700); + --color-ink-800: var(--color-zinc-800); + --color-ink-900: var(--color-zinc-900); + --color-ink-950: var(--color-zinc-950); +} + +/* The remaining component tokens live in the `theme` layer so a consumer's @theme / + @layer theme overrides always win (light and dark stay in the same layer, no cascade upset). */ +@layer theme { + :root { + --color-bg: var(--color-ink-50); /* page base */ + /* --surface-level drives the raised-surface derivation in @theme (--color-surface): + higher = more contrast off the page. Light is flat white; .dark lifts subtly. */ + --surface-level: 100%; + + --shadow-input: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-box: 0 1px 2px 0 rgb(0 0 0 / 0.05); + + --focus-ring-width: 3px; + --focus-ring-offset: 0px; + --focus-ring-color: color-mix(in oklab, var(--color-accent) 30%, transparent); + --focus-ring-offset-color: #ffffff; + } + + .dark { + --color-accent: var(--color-ink-100); + --color-accent-content: var(--color-ink-300); + --color-accent-foreground: var(--color-ink-900); + --color-bg: var(--color-ink-950); /* override to ink-900 for a softer dark, ink-800 for soft */ + --surface-level: 6%; /* subtle lift off the dark page */ + --color-box-border: var(--color-ink-700); + --color-accent-muted: var(--color-ink-100); + --color-muted: var(--color-ink-400); + /* Inputs lift off any surface (page or card) via a translucent lighten. */ + --color-input-bg: color-mix(in oklab, white 4%, transparent); + --focus-ring-offset-color: #18181b; /* zinc-900 */ + } +} + +/* Unified focus indicator. Add `focus-ring` to any interactive element for a crisp accent + border plus a faded, translucent accent ring on focus. Uses `outline` for the ring so it + follows border-radius, never clashes with the element's box-shadow, and is not clipped by + overflow. The accent border-color is a no-op on borderless elements (button/checkbox labels), + so it only shows on bordered controls (inputs, selects, textareas). Themeable via + --focus-ring-width / --focus-ring-color / --focus-ring-offset (see the theme builder). */ +@utility focus-ring { + /* The always-present transparent outline avoids a colour flash and suppresses the + UA default; only the colour changes on focus. */ + outline: var(--focus-ring-width) solid transparent; + outline-offset: var(--focus-ring-offset); + &:focus-visible { + outline-color: var(--focus-ring-color); + border-color: var(--color-accent); + } +} + +/* Range slider, themed to match inputs: a visible filled track and a neutral surface + thumb (no bright accent). Webkit and Moz pseudo-elements are separate rules, a browser + drops the whole rule if it doesn't recognise the selector. */ +.ui-range { + --range-fill-color: var(--color-accent); /* completed portion + thumb; neutral via .ui-range-neutral */ + --range-track-bg: var(--color-input-bg); /* unfilled track: same surface as an input/unchecked checkbox */ + --range-border: var(--color-ink-300); /* track + thumb border, matched to inputs/radio; .dark override below */ + @apply w-full cursor-pointer appearance-none bg-transparent; + &:disabled { @apply cursor-not-allowed opacity-50; } + + /* Track: completed portion (up to --range-fill, set per value) is the accent; the rest + is the unchecked-control bg. Separate webkit/moz rules, a browser drops a rule whose + selector it doesn't know. Colours come from custom props (set on .ui-range, inherited + into the pseudo-element) because Tailwind's `dark:` variant cannot target a nested + pseudo-element — it compiles to ::thumb:where(.dark) which never matches. */ + &::-webkit-slider-runnable-track { + @apply h-2 rounded-full border; + border-color: var(--range-border); + background: linear-gradient(to right, var(--range-fill-color) var(--range-fill, 0%), var(--range-track-bg) var(--range-fill, 0%)); + } + &::-moz-range-track { @apply h-2 rounded-full border; border-color: var(--range-border); background: var(--range-track-bg); } + &::-moz-range-progress { @apply h-2; border-radius: 9999px 0 0 9999px; background: var(--range-fill-color); } + + &::-webkit-slider-thumb { @apply appearance-none size-[1.05rem] -mt-[0.3rem] rounded-full border-2 shadow-sm; border-color: var(--range-border); background: var(--range-fill-color); } + &::-moz-range-thumb { @apply size-[1.05rem] rounded-full border-2 shadow-sm; border-color: var(--range-border); background: var(--range-fill-color); } + + &:focus-visible { @apply outline-none; } + &:focus-visible::-webkit-slider-thumb { outline: var(--focus-ring-width) solid var(--focus-ring-color); outline-offset: 2px; } + &:focus-visible::-moz-range-thumb { outline: var(--focus-ring-width) solid var(--focus-ring-color); outline-offset: 2px; } +} +.dark .ui-range { --range-border: var(--color-ink-700); } +.ui-range-neutral { --range-fill-color: var(--color-ink-400); } +.dark .ui-range-neutral { --range-fill-color: var(--color-ink-500); } + +/* Alpine.js x-cloak */ +[x-cloak] { + display: none !important; +} + +@source inline("!bg-transparent !p-0 !px-0 !ring-zinc-900 -mb-1 -mb-px -ml-1 -mr-1 -mt-1 -rotate-180 -rotate-90 -space-x-2 -translate-x-1/2 -translate-x-full -translate-y-1/2 -translate-y-full [&>*]:-rotate-180 [&>.is-current]:bg-accent [&>.is-current]:bg-accent/15 [&>.is-current]:border-accent [&>.is-current]:text-accent [&>.is-current]:text-accent-content [&>.is-current]:text-accent-foreground [&>.is-current]:text-ink-900 [&>a:hover]:bg-ink-100 [&>a:hover]:text-ink-900 [&>a]:border-b-2 [&>a]:border-transparent [&>a]:font-medium [&>a]:px-1 [&>a]:px-2 [&>a]:px-3 [&>a]:py-2 [&>a]:rounded-control [&>a]:text-ink-600 [&>a]:text-sm [&>a]:transition-colors [&>option]:bg-white [&>option]:dark:bg-ink-900 [&>option]:dark:text-ink-100 [&>option]:text-ink-900 [&>span]:ring-2 [&>span]:ring-white [&>svg]:h-4 [&>svg]:size-4 [&>svg]:w-4 [&_.is-current]:bg-ink-100 [&_.is-current]:border-accent [&_.is-current]:font-semibold [&_.is-current]:text-ink-900 [&_a:hover]:bg-ink-50 [&_a:hover]:text-ink-900 [&_a]:-mb-px [&_a]:border-b-2 [&_a]:border-ink-200 [&_a]:border-l-2 [&_a]:border-transparent [&_a]:flex [&_a]:font-medium [&_a]:font-normal [&_a]:gap-2 [&_a]:gap-3 [&_a]:inline-flex [&_a]:items-center [&_a]:justify-between [&_a]:pl-4 [&_a]:pr-2 [&_a]:px-3 [&_a]:px-4 [&_a]:py-1 [&_a]:py-2 [&_a]:py-3 [&_a]:relative [&_a]:rounded-control [&_a]:rounded-t-md [&_a]:text-ink-600 [&_a]:text-sm [&_a]:transition-colors [&_tbody_tr:hover]:bg-ink-50 [&_tbody_tr:last-child]:border-0 [&_tbody_tr]:border-b [&_tbody_tr]:border-ink-100 [&_td]:px-3 [&_td]:py-2.5 [&_td]:text-ink-700 [&_th]:font-medium [&_th]:px-3 [&_th]:py-2.5 [&_th]:text-ink-500 [&_th]:whitespace-nowrap [&_thead]:border-b [&_thead]:border-ink-200 absolute accent-[var(--color-accent)] active:opacity-100 animate-spin appearance-none backdrop-blur backdrop-blur-sm before:content-[var(--bc-separator)] before:mr-0.5 before:text-ink-300 bg-[color-mix(in_oklab,var(--color-accent-muted,currentColor)_5%,transparent)] bg-[right_0.5rem_center] bg-[var(--color-accent-muted,currentColor)] bg-[var(--color-input-bg)] bg-[var(--color-surface)] bg-accent bg-accent/10 bg-accent/5 bg-amber-100 bg-amber-300 bg-amber-400 bg-amber-50 bg-amber-500 bg-amber-600 bg-amber-950 bg-black/20 bg-black/50 bg-blue-100 bg-blue-300 bg-blue-400 bg-blue-50 bg-blue-500 bg-blue-600 bg-blue-950 bg-cyan-100 bg-cyan-300 bg-cyan-400 bg-cyan-500 bg-cyan-600 bg-cyan-950 bg-emerald-100 bg-emerald-300 bg-emerald-400 bg-emerald-500 bg-emerald-600 bg-emerald-950 bg-fuchsia-100 bg-fuchsia-300 bg-fuchsia-400 bg-fuchsia-500 bg-fuchsia-600 bg-fuchsia-950 bg-gray-100 bg-gray-200 bg-gray-300 bg-gray-400 bg-gray-50 bg-gray-500 bg-gray-600 bg-gray-700 bg-gray-800 bg-gray-900 bg-gray-950 bg-green-100 bg-green-300 bg-green-400 bg-green-50 bg-green-500 bg-green-600 bg-green-950 bg-indigo-100 bg-indigo-300 bg-indigo-400 bg-indigo-500 bg-indigo-600 bg-indigo-950 bg-ink-100 bg-ink-200 bg-ink-300 bg-ink-400 bg-ink-50 bg-ink-500 bg-ink-600 bg-ink-700 bg-ink-800 bg-ink-900 bg-ink-950 bg-lime-100 bg-lime-300 bg-lime-400 bg-lime-500 bg-lime-600 bg-lime-950 bg-mauve-100 bg-mauve-200 bg-mauve-300 bg-mauve-400 bg-mauve-50 bg-mauve-500 bg-mauve-600 bg-mauve-700 bg-mauve-800 bg-mauve-900 bg-mauve-950 bg-mist-100 bg-mist-200 bg-mist-300 bg-mist-400 bg-mist-50 bg-mist-500 bg-mist-600 bg-mist-700 bg-mist-800 bg-mist-900 bg-mist-950 bg-neutral-100 bg-neutral-200 bg-neutral-300 bg-neutral-400 bg-neutral-50 bg-neutral-500 bg-neutral-600 bg-neutral-700 bg-neutral-800 bg-neutral-900 bg-neutral-950 bg-no-repeat bg-olive-100 bg-olive-200 bg-olive-300 bg-olive-400 bg-olive-50 bg-olive-500 bg-olive-600 bg-olive-700 bg-olive-800 bg-olive-900 bg-olive-950 bg-orange-100 bg-orange-300 bg-orange-400 bg-orange-500 bg-orange-600 bg-orange-950 bg-pink-100 bg-pink-300 bg-pink-400 bg-pink-500 bg-pink-600 bg-pink-950 bg-purple-100 bg-purple-300 bg-purple-400 bg-purple-500 bg-purple-600 bg-purple-950 bg-red-100 bg-red-300 bg-red-400 bg-red-50 bg-red-500 bg-red-600 bg-red-950 bg-rose-100 bg-rose-300 bg-rose-400 bg-rose-500 bg-rose-600 bg-rose-950 bg-sky-100 bg-sky-300 bg-sky-400 bg-sky-500 bg-sky-600 bg-sky-950 bg-slate-100 bg-slate-200 bg-slate-300 bg-slate-400 bg-slate-50 bg-slate-500 bg-slate-600 bg-slate-700 bg-slate-800 bg-slate-900 bg-slate-950 bg-stone-100 bg-stone-200 bg-stone-300 bg-stone-400 bg-stone-50 bg-stone-500 bg-stone-600 bg-stone-700 bg-stone-800 bg-stone-900 bg-stone-950 bg-taupe-100 bg-taupe-200 bg-taupe-300 bg-taupe-400 bg-taupe-50 bg-taupe-500 bg-taupe-600 bg-taupe-700 bg-taupe-800 bg-taupe-900 bg-taupe-950 bg-teal-100 bg-teal-300 bg-teal-400 bg-teal-500 bg-teal-600 bg-teal-950 bg-transparent bg-violet-100 bg-violet-300 bg-violet-400 bg-violet-500 bg-violet-600 bg-violet-950 bg-white bg-white/80 bg-white/90 bg-yellow-100 bg-yellow-300 bg-yellow-400 bg-yellow-500 bg-yellow-600 bg-yellow-950 bg-zinc-100 bg-zinc-500 bg-zinc-700 bg-zinc-900 block border border-0 border-2 border-[color:var(--color-box-border)] border-[color:var(--color-card-border,var(--color-box-border))] border-[var(--color-accent-muted,currentColor)] border-accent border-amber-200 border-amber-300 border-b border-b-2 border-blue-200 border-blue-300 border-collapse border-green-200 border-green-300 border-ink-100 border-ink-200 border-ink-300 border-ink-400 border-ink-900 border-l border-r border-red-200 border-red-300 border-red-500 border-t border-transparent border-zinc-200 bottom-0 bottom-4 collapse container contents cursor-not-allowed cursor-pointer cursor-text dark dark:!ring-white dark:[&>.is-current]:bg-accent/20 dark:[&>.is-current]:text-accent dark:[&>.is-current]:text-white dark:[&>a:hover]:bg-ink-800 dark:[&>a:hover]:text-white dark:[&>a]:text-ink-300 dark:[&>a]:text-ink-400 dark:[&>span]:ring-ink-900 dark:[&_.is-current]:bg-ink-800 dark:[&_.is-current]:text-ink-100 dark:[&_.is-current]:text-white dark:[&_a:hover]:bg-ink-800/50 dark:[&_a:hover]:text-ink-100 dark:[&_a]:border-ink-800 dark:[&_a]:text-ink-400 dark:[&_tbody_tr:hover]:bg-ink-800/50 dark:[&_tbody_tr]:border-ink-800 dark:[&_td]:text-ink-300 dark:[&_th]:text-ink-400 dark:[&_thead]:border-ink-700 dark:before:text-ink-600 dark:bg-amber-500 dark:bg-amber-900/30 dark:bg-amber-950/40 dark:bg-amber-950/90 dark:bg-blue-500 dark:bg-blue-900/30 dark:bg-blue-950/40 dark:bg-blue-950/90 dark:bg-cyan-500 dark:bg-cyan-900/30 dark:bg-emerald-500 dark:bg-emerald-900/30 dark:bg-fuchsia-500 dark:bg-fuchsia-900/30 dark:bg-green-500 dark:bg-green-900/30 dark:bg-green-950/40 dark:bg-green-950/90 dark:bg-indigo-500 dark:bg-indigo-900/30 dark:bg-ink-300 dark:bg-ink-300/10 dark:bg-ink-400 dark:bg-ink-400/30 dark:bg-ink-700 dark:bg-ink-800 dark:bg-ink-900 dark:bg-ink-900/80 dark:bg-ink-950 dark:bg-lime-500 dark:bg-lime-900/30 dark:bg-orange-500 dark:bg-orange-900/30 dark:bg-pink-500 dark:bg-pink-900/30 dark:bg-purple-500 dark:bg-purple-900/30 dark:bg-red-500 dark:bg-red-900/30 dark:bg-red-950/40 dark:bg-red-950/90 dark:bg-rose-500 dark:bg-rose-900/30 dark:bg-sky-500 dark:bg-sky-900/30 dark:bg-teal-500 dark:bg-teal-900/30 dark:bg-violet-500 dark:bg-violet-900/30 dark:bg-white dark:bg-yellow-500 dark:bg-yellow-900/30 dark:bg-zinc-300 dark:bg-zinc-400 dark:bg-zinc-700 dark:bg-zinc-800 dark:bg-zinc-800/90 dark:bg-zinc-900 dark:border-amber-800 dark:border-amber-900 dark:border-blue-800 dark:border-blue-900 dark:border-green-800 dark:border-green-900 dark:border-ink-100 dark:border-ink-300/20 dark:border-ink-600 dark:border-ink-700 dark:border-ink-800 dark:border-red-800 dark:border-red-900 dark:border-zinc-700 dark:border-zinc-800 dark:divide-ink-700 dark:focus-visible:bg-ink-800 dark:focus-visible:bg-red-950/30 dark:hover:bg-ink-600 dark:hover:bg-ink-700 dark:hover:bg-ink-700/50 dark:hover:bg-ink-800 dark:hover:bg-red-950/30 dark:hover:bg-zinc-700/50 dark:hover:bg-zinc-800 dark:hover:border-ink-600 dark:hover:text-ink-100 dark:hover:text-ink-200 dark:hover:text-ink-300 dark:hover:text-white dark:hover:text-zinc-200 dark:placeholder:text-ink-500 dark:ring-offset-zinc-900 dark:ring-white/15 dark:text-amber-200 dark:text-amber-300 dark:text-amber-400 dark:text-blue-200 dark:text-blue-300 dark:text-blue-400 dark:text-cyan-400 dark:text-emerald-400 dark:text-fuchsia-400 dark:text-green-200 dark:text-green-300 dark:text-green-400 dark:text-indigo-400 dark:text-ink-100 dark:text-ink-200 dark:text-ink-300 dark:text-ink-400 dark:text-ink-50 dark:text-ink-500 dark:text-ink-600 dark:text-ink-900 dark:text-lime-400 dark:text-orange-400 dark:text-pink-400 dark:text-purple-400 dark:text-red-200 dark:text-red-300 dark:text-red-400 dark:text-rose-400 dark:text-sky-400 dark:text-teal-400 dark:text-violet-400 dark:text-white dark:text-yellow-400 dark:text-zinc-100 dark:text-zinc-200 dark:text-zinc-300 dark:text-zinc-400 dark:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:opacity-75 disabled:pointer-events-none divide-ink-200 divide-y duration-100 duration-150 duration-200 duration-300 duration-500 duration-75 ease-in ease-in-out ease-linear ease-out first:before:content-[''] first:pt-0 first:rounded-l-[var(--radius-control)] fixed flex flex-1 flex-col flex-col-reverse flex-shrink-0 flex-wrap focus-ring focus-visible:bg-ink-100 focus-visible:bg-red-50 focus-visible:outline-none focus-within:border-accent focus-within:ring-[length:var(--focus-ring-width)] focus-within:ring-[var(--focus-ring-color)] focus:outline-none focus:ring-0 font-medium font-mono font-normal font-semibold gap-1 gap-1.5 gap-2 gap-2.5 gap-3 gap-4 gap-x-6 gap-y-2 grid grid-cols-3 grid-cols-7 h-1 h-1.5 h-14 h-16 h-2 h-24 h-3 h-32 h-4 h-48 h-5 h-6 h-64 h-7 h-8 h-80 h-9 h-[1.125rem] h-full h-px hidden hover:bg-accent-content hover:bg-accent/10 hover:bg-ink-100 hover:bg-ink-200 hover:bg-ink-50 hover:bg-red-50 hover:bg-red-500/10 hover:bg-red-600 hover:bg-zinc-100 hover:bg-zinc-50 hover:border-ink-300 hover:opacity-100 hover:opacity-80 hover:scale-110 hover:text-accent-content hover:text-ink-600 hover:text-ink-700 hover:text-ink-900 hover:text-zinc-800 hover:text-zinc-900 hover:underline inline inline-block inline-flex inset-0 inset-x-0 inset-y-0 invisible is-current items-center items-end items-start justify-between justify-center justify-start last:border-r-0 last:rounded-r-[var(--radius-control)] leading-[1.45] leading-none left-0 left-1/2 left-3 left-4 light max-h-44 max-h-60 max-h-[40vh] max-h-[55vh] max-h-[70vh] max-h-[82vh] max-h-[85vh] max-h-[92vh] max-h-[calc(100vh-4rem)] max-w-2xl max-w-4xl max-w-[7.5rem] max-w-[calc(100vw-2rem)] max-w-full max-w-lg max-w-md max-w-sm max-w-xl max-w-xs mb-1.5 mb-2 mb-4 md:absolute md:flex md:hidden md:min-w-[var(--dd-min)] md:mt-0 md:w-auto min-h-10 min-h-11 min-h-12 min-h-14 min-h-16 min-h-5 min-h-8 min-h-9 min-h-[3.5rem] min-h-[42px] min-w-0 min-w-9 min-w-[var(--dd-min)] ml-1.5 ml-2 ml-auto mr-0.5 mr-1 mr-2 mt-0.5 mt-1 mt-1.5 mt-2 mt-4 my-1 no-underline object-cover opacity-0 opacity-100 opacity-25 opacity-50 opacity-60 opacity-70 opacity-75 opacity-80 opacity-90 outline overflow-auto overflow-hidden overflow-visible overflow-x-auto overflow-x-hidden overflow-y-auto p-0.5 p-1 p-1.5 p-10 p-12 p-2 p-2.5 p-3 p-4 p-6 p-8 pb-0 pb-2 pb-2.5 pb-3 pb-4 pl-10 pl-2 pl-2.5 pl-3 pl-9 placeholder:text-ink-400 pointer-events-auto pointer-events-none pr-10 pr-12 pr-14 pr-2 pr-3 pr-7 pr-8 pt-0 pt-1 pt-2 pt-3 pt-4 pt-5 px-1 px-1.5 px-2 px-2.5 px-3 px-3.5 px-4 px-5 px-6 py-0.5 py-1 py-1.5 py-2 py-2.5 py-3 py-3.5 py-4 py-6 relative resize resize-none right-0 right-2 right-4 ring ring-1 ring-2 ring-[length:var(--focus-ring-width)] ring-[var(--focus-ring-color)] ring-black/10 ring-offset-2 ring-offset-white rotate-0 rotate-180 rotate-90 rounded rounded-[calc(var(--radius-control)*0.5)] rounded-[calc(var(--radius-control)*0.66)] rounded-box rounded-button rounded-control rounded-full rounded-t-md scale-0 scale-100 scale-95 select-none shadow shadow-[var(--shadow-box)] shadow-[var(--shadow-input)] shadow-lg shadow-sm shadow-xl shrink shrink-0 size-10 size-11 size-12 size-16 size-2.5 size-2/3 size-3 size-3.5 size-4 size-5 size-6 size-8 size-full sm:flex-row sm:justify-end sm:max-h-[80vh] sm:space-x-2 sm:text-left space-y-1 space-y-1.5 space-y-2.5 space-y-3 space-y-4 space-y-5 sr-only static sticky table tabular-nums text-2xl text-[10px] text-[11px] text-accent text-accent-foreground text-amber-700 text-amber-800 text-base text-blue-700 text-blue-800 text-center text-current text-cyan-700 text-emerald-700 text-fuchsia-700 text-green-700 text-green-800 text-indigo-700 text-ink-300 text-ink-400 text-ink-500 text-ink-600 text-ink-700 text-ink-800 text-ink-900 text-ink-950 text-left text-lg text-lime-700 text-orange-700 text-pink-700 text-purple-700 text-red-500 text-red-600 text-red-700 text-red-800 text-right text-rose-700 text-sky-700 text-sm text-teal-700 text-violet-700 text-white text-xl text-xs text-yellow-700 text-zinc-400 text-zinc-500 text-zinc-600 text-zinc-700 text-zinc-800 text-zinc-900 top-0 top-1/2 top-4 tracking-tight tracking-wider transform transition transition-[background-color,color] transition-[background-color] transition-[width] transition-all transition-colors transition-opacity transition-transform translate-x-0 translate-x-0.5 translate-x-[0.875rem] translate-x-[1.125rem] translate-x-[1.375rem] translate-x-[1.875rem] translate-x-[2.125rem] translate-x-[2.375rem] translate-x-full translate-y-0 translate-y-1 translate-y-2 translate-y-full truncate ui-range ui-range-neutral underline uppercase visible w-10 w-11 w-14 w-16 w-2 w-28 w-3 w-36 w-4 w-5 w-6 w-7 w-8 w-80 w-9 w-[1.125rem] w-[280px] w-[340px] w-[4.5rem] w-full w-max w-screen whitespace-nowrap z-10 z-40 z-50 z-[100] z-[90] z-[99]"); diff --git a/twisted/tailwindcsstheme/static_src/src/styles.css b/twisted/tailwindcsstheme/static_src/src/styles.css index 088fd6c..274dea9 100644 --- a/twisted/tailwindcsstheme/static_src/src/styles.css +++ b/twisted/tailwindcsstheme/static_src/src/styles.css @@ -1,61 +1,140 @@ @import "tailwindcss"; +@import "./cotton-ui.sources.css"; -/** -* A catch-all path to Django template files, JavaScript, and Python files -* that contain Tailwind CSS classes and will be scanned by Tailwind to generate the final CSS file. -* -* If your final CSS file is not being updated after code changes, you may want to broaden or narrow -* the scope of this path. -*/ @source "../../../**/*.{html,py,js}"; - -button { - cursor: pointer; -} +@source "../../../../.venv/lib/python3.13/site-packages/django_cotton_ui/templates/**/*.html"; @font-face { - font-family: "Pixelated MS Sans Serif"; - src: url("/static/fonts/ms_sans_serif.woff") format("woff"); - src: url("/static/fonts/ms_sans_serif.woff2") format("woff2"); - font-weight: normal; + font-family: "Figtree"; font-style: normal; + font-weight: 300 900; + font-display: swap; + src: url(https://fonts.gstatic.com/s/figtree/v9/_Xms-HUzqDCFdgfMm4S9DbZs.woff2) format("woff2"); } @font-face { - font-family: "Pixelated MS Sans Serif"; - src: url("/static/fonts/ms_sans_serif_bold.woff") format("woff"); - src: url("/static/fonts/ms_sans_serif_bold.woff2") format("woff2"); - font-weight: bold; + font-family: "Pirkkala"; + src: url("/static/fonts/pirkkala.ttf") format("truetype"); + font-weight: 200; font-style: normal; + font-display: swap; } @font-face { - font-family: 'Pirkalla'; - src: url('/static/fonts/pirkkala.ttf') format('truetype'); - font-weight: 400; + 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); + --color-accent-content: var(--color-red-600); + --color-accent-foreground: #ffffff; + + /* Corner radius, by role */ + --radius-control: 0.375rem; + --radius-box: 0.5rem; + + /* Form controls */ + --focus-ring-width: 3px; + --shadow-input: 0 1px 2px 0 rgb(0 0 0 / 0.05); + + /* Structural surfaces */ + --shadow-box: 0 1px 2px 0 rgb(0 0 0 / 0.05); +} + +.dark { + /* Dark mode: lighter accent so it stays vivid on dark surfaces */ + --color-accent: var(--color-red-400); + --color-accent-content: var(--color-red-300); + --color-accent-foreground: var(--color-red-950); + --color-primary: var(--color-red-600); + --color-primary-foreground: var(--color-accent-foreground); + --color-surface: var(--color-zinc-900) +} + +.os { + --color-accent: #E0A7FF; + --color-accent-foreground: #24102F; + + --color-primary: #B96CFF; + --color-primary-foreground: #190B24; + + --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 { - --font-vintage: "Pirkalla", monospace, sans-serif; + --font-vintage: "Pirkkala", sans-serif; + --font-latin: "Figtree", sans-serif; + + --background-image-gradbg: radial-gradient(circle, var(--color-background) 33%, rgba(9, 11, 15, 1) 100%); + + --color-background: #24102F; + --color-foreground: #FAF7FF; + + --color-card: #1B0D25; + --color-card-foreground: #FAF7FF; + + --color-popover: #48245C; + --color-popover-foreground: #FFFFFF; + + --color-primary: #B96CFF; + --color-primary-foreground: #190B24; + + --color-secondary: #5B2E73; + --color-secondary-foreground: #F1DFFF; - --color-absolute-white: #FAFAFA; - --color-fair-gray: #A1A1A1; - --color-dark-black: #050505; + --color-accent: #E0A7FF; + --color-accent-foreground: #24102F; - --color-twisted-200: #CAB3E1; - --color-twisted-400: #8364A2; - --color-twisted-600: #5A4471; - --color-twisted-800: #36214B; - --color-twisted-999: #29153D; + --color-muted: #432252; + --color-muted-foreground: #C9B8D3; - --color-surface: #1E072F; - --color-dark: #261F2D; + --color-subtle: #806A8D; + --color-subtle-foreground: #E2D8E8; + + --color-border: #654078; + --color-input: #75488A; + --color-ring: #C27AFF; + + --color-success: #4ADE80; + --color-success-foreground: #092414; + + --color-warning: #FACC15; + --color-warning-foreground: #2A2105; + + --color-destructive: #FF6B7A; + --color-destructive-foreground: #2A080D; +} + +button { + cursor: pointer; } + @keyframes flag-wave { - 0%, 100% { transform: rotate(-2deg); } - 50% { transform: rotate(2deg); } + + 0%, + 100% { + transform: rotate(-2deg); + } + + 50% { + transform: rotate(2deg); + } } .flag-wave { @@ -63,27 +142,115 @@ button { animation: flag-wave 4s ease-in-out infinite; } -.scrollbar-win9x::-webkit-scrollbar { - width: 16px; +@utility no-spinner { + appearance: textfield; + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } +} + +@utility image-render-pixelated { + image-rendering: crisp-edges; + 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; +} + +::-webkit-scrollbar-track { + background: #1E072F; +} + +::-webkit-scrollbar-thumb { + background: var(--color-primary); + border: 2px solid #1E072F; + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-accent); +} + +::-webkit-scrollbar-corner { + background: #1E072F; } -.scrollbar-win9x::-webkit-scrollbar-track { - background-color: #c0c0c0; - border-left: 2px solid #dfdfdf; +@utility no-scrollbar { + @apply [scrollbar-width:none] [&::-webkit-scrollbar]:hidden; } -.scrollbar-win9x::-webkit-scrollbar-thumb { - background-color: #c0c0c0; - border-top: 2px solid #ffffff; - border-left: 2px solid #ffffff; - border-right: 2px solid #0a0a0a; - border-bottom: 2px solid #0a0a0a; +::-webkit-scrollbar-corner { + background: #1E072F; +} +.resize-handle { + position: absolute; + z-index: 10; } -.scrollbar-win9x::-webkit-scrollbar-thumb:focus { - background-color: #FFFFFF; - border-top: 2px solid #ffffff; - border-left: 2px solid #ffffff; - border-right: 2px solid #0a0a0a; - border-bottom: 2px solid #0a0a0a; +.resize-n, +.resize-s { + left: 8px; + right: 8px; + height: 6px; + cursor: ns-resize; } + +.resize-n { top: 0; } +.resize-s { bottom: 0; } + +.resize-e, +.resize-w { + top: 8px; + bottom: 8px; + width: 6px; + cursor: ew-resize; +} + +.resize-e { right: 0; } +.resize-w { left: 0; } + +.resize-ne, +.resize-nw, +.resize-se, +.resize-sw { + width: 8px; + height: 8px; +} + +.resize-ne { top: 0; right: 0; cursor: ne-resize; } +.resize-nw { top: 0; left: 0; cursor: nw-resize; } +.resize-se { bottom: 0; right: 0; cursor: se-resize; } +.resize-sw { bottom: 0; left: 0; cursor: sw-resize; } diff --git a/twisted/tailwindcsstheme/static_src/src/win98.css b/twisted/tailwindcsstheme/static_src/src/win98.css deleted file mode 100644 index 8dcd7e2..0000000 --- a/twisted/tailwindcsstheme/static_src/src/win98.css +++ /dev/null @@ -1,55 +0,0 @@ -@import "../node_modules/98.css/style.css"; - - - - -/** -* A catch-all path to Django template files, JavaScript, and Python files -* that contain Tailwind CSS classes and will be scanned by Tailwind to generate the final CSS file. -* -* If your final CSS file is not being updated after code changes, you may want to broaden or narrow -* the scope of this path. -*/ -@source "../../../**/*.{html,py,js}"; - -button, -label, -input, -legend, -textarea, -select, -option, -table, -ul.tree-view, -.window, -.title-bar, -li[role="tab"] { - font-family: "Pirkalla", Arial; - -webkit-font-smoothing: none; - font-size: 11px; -} - -:root { - --dialog-blue: #261f2d; - --dialog-blue-light: var(--dialog-blue); - - --button-highlight: #36214B; - - --surface: #1E072F; - --text-color: #ffffff; - --link-blue: #8f8ff4 !important; -} - -.sunken-panel { - background-color: #261f2d; -} - -select:focus { - color: var(--text-color); - background-color: #29153D; -} - -select:focus option { - color: var(--text-color); - background-color: var(--surface); -} \ No newline at end of file diff --git a/twisted/twisted_site/admin.py b/twisted/twisted_site/admin.py index 07dba64..8c38f3f 100644 --- a/twisted/twisted_site/admin.py +++ b/twisted/twisted_site/admin.py @@ -1,7 +1,3 @@ from django.contrib import admin -from .models import Profile, Project, Journal # Register your models here. -admin.site.register(Profile) -admin.site.register(Project) -admin.site.register(Journal) diff --git a/twisted/twisted_site/ari.py b/twisted/twisted_site/ari.py new file mode 100644 index 0000000..682182e --- /dev/null +++ b/twisted/twisted_site/ari.py @@ -0,0 +1,180 @@ +import hashlib +import hmac +import json +import time +from collections.abc import Iterable +from typing import Literal + +import requests +from django.conf import settings + +from .models import Journal, Project, ProjectShip + + +ARI_INGEST_ENDPOINT = settings.ARI_INGEST_ENDPOINT +ARI_SIGNING_SECRET = settings.ARI_SIGNING_SECRET +ARI_WEBHOOK_SECRET = settings.ARI_WEBHOOK_SECRET + +# Deliveries older than this are rejected, per the "How delivery works" doc. +WEBHOOK_MAX_AGE_SECONDS = 5 * 60 + + +def verify_webhook_signature(body: bytes, timestamp: str, delivery_id: str, signature: str) -> bool: + """Verifies an outbound delivery from Ari (the X-Ari-Signature/-Timestamp/-Delivery-Id + headers on review.* and ship.updated webhooks). Signed with ARI_WEBHOOK_SECRET, which is + separate from ARI_SIGNING_SECRET (that one signs requests we send to Ari).""" + if not (timestamp and delivery_id and signature): + return False + + try: + if abs(time.time() - int(timestamp)) > WEBHOOK_MAX_AGE_SECONDS: + return False + except ValueError: + return False + + key_bytes = ARI_WEBHOOK_SECRET.encode("utf-8") + message_bytes = f"{timestamp}.{delivery_id}.".encode("utf-8") + body + expected_signature = hmac.new(key_bytes, message_bytes, hashlib.sha256).hexdigest() + + return hmac.compare_digest(expected_signature, signature) + + +def get_hex_signature(content): + key_bytes = ARI_SIGNING_SECRET.encode("utf-8") + try: + message_bytes = content.encode("utf-8") + except Exception: + message_bytes = content + + hmac_object = hmac.new(key_bytes, message_bytes, hashlib.sha256) + hex_signature = hmac_object.hexdigest() + + return hex_signature + +def send_request(method: Literal["GET", "POST"], data=None, endpoint="", jsonify=True): + if jsonify or data is None: + data = json.dumps(data) + + if method == 'POST': + headers = { + "X-Ari-Signature": get_hex_signature(data), + "Content-Type": "application/json", + } + message_bytes = data.encode("utf-8") + else: + message_bytes = None + headers = { + "Authorization": f"Bearer {ARI_SIGNING_SECRET}" + } + req = requests.request( + method, + ARI_INGEST_ENDPOINT + endpoint, + data=message_bytes, + headers=headers, + ) + return req + +# external_id = "twisted-{project.id}" +def send_ship(ship: ProjectShip): + if settings.DEBUG_REVIEW: + return + external_id = f"twisted-{ship.project.id}" + + untracked_time = 0 + if ship.project.project_type == "hardware": + for journal in ship.project.journals.filter(type="untracked"): + untracked_time += journal.reduced_minutes + + maker = { + "email": ship.project.user.email, + "name": ship.project.user.profile.slack_username, + "slack_id": ship.project.user.profile.slack_id, + "program_hours": untracked_time/60 + } + + title = ship.project.project_name + description = ship.project.project_description + + repo_url = ship.project.repo_url + demo_url = ship.project.playable_url + + track = ship.project.project_type + shipped_at = ship.created_at.isoformat() + + thumbnail_url = ship.project.screenshot_url + + hackatime_projects = [ship.project.hackatime_project_name] + + meta = { + "project_url": f"https://twisted.hackclub.com/dashboard/?project={ship.project.id}", + "admin_project_url": f"https://twisted.hackclub.com/admin/projects/{ship.project.id}", + } + + journals = [] + orm_journals: Iterable[Journal] = ship.project.journals.all() + for journal in orm_journals: + content = f"# Journal type: {journal.get_type_display()}\n\n{journal.content}" + journals.append( + { + "at": journal.created_at.isoformat(), + "minutes": journal.reduced_minutes, + "text": content, + "markdown": content, + } + ) + + r = send_request( + "POST", + { + "external_id": external_id, + "maker": maker, + "title": title, + "description": description, + "repo_url": repo_url, + "demo_url": demo_url, + "track": track, + "shipped_at": shipped_at, + "thumbnail_url": thumbnail_url, + "hackatime_projects": hackatime_projects, + "evidence": ['commits', 'elapsed', 'devlog'], + "journals": journals, + "meta": meta + }, + ) + resp = r.content + r.raise_for_status() + +def get_project_status(project: Project): + r = send_request('GET', endpoint=f"/status?external_id=twisted-{project.id}") + _resp = r.content + r.raise_for_status() + return r.json() + +# ARI's phases go: (processing | fraud_review | review | under_review) -- reviewer +# hasn't decided yet -- then second_pass -- reviewer decided, an organizer still has +# to confirm it -- then reviewed, where `decision` is locked in. withdrawn/reverted +# are terminal/reset states outside that normal flow. +_ARI_DECISION_TO_SHIP_STATUS = { + "approved": "approved", + "changes": "requested_changes", + "rejected": "rejected", +} + + +def ship_passes_from_status(status: dict | None) -> tuple[str, str]: + """Maps an ARI /status response into (first_pass_status, second_pass_status), + using the PROJECT_SHIP_STATUSES vocabulary (pending/approved/rejected/requested_changes).""" + if not status: + return "pending", "pending" + + phase = status.get("phase") + decision = _ARI_DECISION_TO_SHIP_STATUS.get(status.get("decision"), "pending") + + if phase == "second_pass": + return decision, "pending" + if phase == "reviewed": + return decision, decision + if phase == "withdrawn": + return "rejected", "rejected" + # processing, fraud_review, review, under_review, reverted, or unknown + return "pending", "pending" diff --git a/twisted/twisted_site/hackatime.py b/twisted/twisted_site/hackatime.py index 904a0f4..91f49a6 100644 --- a/twisted/twisted_site/hackatime.py +++ b/twisted/twisted_site/hackatime.py @@ -52,13 +52,15 @@ def projects( projects: list[str] | None = None, ) -> list[HackatimeProject]: """Returns the user's projects with time totals.""" + params = {"include_archived": "true" if include_archived else "false"} + if start is not None: + params["start"] = start.isoformat() + if projects is not None: + params["projects"] = ",".join(projects) + resp = requests.get( - ( - HACKATIME_ROOT_URL + "/api/v1/authenticated/projects" - f"?include_archived={'true' if include_archived else 'false'}" - f"&start={str(start.isoformat()) if start is not None else ''}" - f"&projects={','.join(projects) if projects is not None else ''}" - ), + HACKATIME_ROOT_URL + "/api/v1/authenticated/projects", + params=params, headers=authhelper(access_token), ) resp.raise_for_status() diff --git a/twisted/twisted_site/migrations/0009_uploadedimage.py b/twisted/twisted_site/migrations/0009_uploadedimage.py new file mode 100644 index 0000000..7a1e1d1 --- /dev/null +++ b/twisted/twisted_site/migrations/0009_uploadedimage.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.7 on 2026-08-04 14:06 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0008_journal_reduced_minutes_alter_journal_content'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UploadedImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('link', models.CharField(max_length=500)), + ('cdn_response', models.JSONField()), + ('uploaded_thru', models.CharField(max_length=500)), + ('filesize', models.IntegerField()), + ('uploaded_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/twisted/twisted_site/migrations/0010_rename_uploadedimage_uploadedfile.py b/twisted/twisted_site/migrations/0010_rename_uploadedimage_uploadedfile.py new file mode 100644 index 0000000..d5a2236 --- /dev/null +++ b/twisted/twisted_site/migrations/0010_rename_uploadedimage_uploadedfile.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.7 on 2026-08-04 14:08 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0009_uploadedimage'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RenameModel( + old_name='UploadedImage', + new_name='UploadedFile', + ), + ] diff --git a/twisted/twisted_site/migrations/0011_journal_type.py b/twisted/twisted_site/migrations/0011_journal_type.py new file mode 100644 index 0000000..b99b43d --- /dev/null +++ b/twisted/twisted_site/migrations/0011_journal_type.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.7 on 2026-08-06 15:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0010_rename_uploadedimage_uploadedfile'), + ] + + operations = [ + migrations.AddField( + model_name='journal', + name='type', + field=models.CharField(choices=[('hackatime', 'Hackatime'), ('lookout', 'Lookout'), ('untracked', 'Untracked')], default='hackatime', max_length=100), + preserve_default=False, + ), + ] diff --git a/twisted/twisted_site/migrations/0012_project_repo_url.py b/twisted/twisted_site/migrations/0012_project_repo_url.py new file mode 100644 index 0000000..511ccec --- /dev/null +++ b/twisted/twisted_site/migrations/0012_project_repo_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-08 07:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0011_journal_type'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='repo_url', + field=models.CharField(blank=True, default='', max_length=200), + ), + ] diff --git a/twisted/twisted_site/migrations/0013_projectship.py b/twisted/twisted_site/migrations/0013_projectship.py new file mode 100644 index 0000000..b799efc --- /dev/null +++ b/twisted/twisted_site/migrations/0013_projectship.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.7 on 2026-08-08 10:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0012_project_repo_url'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectShip', + 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)), + ('t1_updated_at', models.DateTimeField(default=None, null=True)), + ('t2_updated_at', models.DateTimeField(default=None, null=True)), + ('t1_message', models.TextField(blank=True, default='')), + ('t2_message', models.TextField(blank=True, default='')), + ('status', models.CharField(choices=[('created', 'Newly created'), ('rejected', 'Rejected ship'), ('reqchecked', 'Checked by T1'), ('approved', 'Approved by T2')], default='created', max_length=200)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ships', to='twisted_site.project')), + ], + ), + ] diff --git a/twisted/twisted_site/migrations/0014_profile_is_staff.py b/twisted/twisted_site/migrations/0014_profile_is_staff.py new file mode 100644 index 0000000..cdf75cb --- /dev/null +++ b/twisted/twisted_site/migrations/0014_profile_is_staff.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-09 14:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0013_projectship'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_staff', + field=models.BooleanField(default=False), + ), + ] diff --git a/twisted/twisted_site/migrations/0015_profile_dark_theme.py b/twisted/twisted_site/migrations/0015_profile_dark_theme.py new file mode 100644 index 0000000..dce9602 --- /dev/null +++ b/twisted/twisted_site/migrations/0015_profile_dark_theme.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-10 14:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0014_profile_is_staff'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='dark_theme', + field=models.BooleanField(default=True), + ), + ] diff --git a/twisted/twisted_site/migrations/0016_profile_ysws_eligible.py b/twisted/twisted_site/migrations/0016_profile_ysws_eligible.py new file mode 100644 index 0000000..9342104 --- /dev/null +++ b/twisted/twisted_site/migrations/0016_profile_ysws_eligible.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-14 13:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0015_profile_dark_theme'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='ysws_eligible', + field=models.BooleanField(default=False), + ), + ] diff --git a/twisted/twisted_site/migrations/0017_remove_profile_dark_theme_profile_is_allowed_and_more.py b/twisted/twisted_site/migrations/0017_remove_profile_dark_theme_profile_is_allowed_and_more.py new file mode 100644 index 0000000..950fa21 --- /dev/null +++ b/twisted/twisted_site/migrations/0017_remove_profile_dark_theme_profile_is_allowed_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.7 on 2026-08-15 09:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0016_profile_ysws_eligible'), + ] + + operations = [ + migrations.RemoveField( + model_name='profile', + name='dark_theme', + ), + migrations.AddField( + model_name='profile', + name='is_allowed', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='projectship', + name='status', + field=models.CharField(choices=[('created', 'Awaiting review'), ('rejected', 'Rejected ship'), ('requested_changes', 'Requested Changes'), ('reqchecked', 'Checked by T1'), ('approved', 'Approved by T2')], default='created', max_length=200), + ), + ] diff --git a/twisted/twisted_site/migrations/0018_pathway.py b/twisted/twisted_site/migrations/0018_pathway.py new file mode 100644 index 0000000..84d114a --- /dev/null +++ b/twisted/twisted_site/migrations/0018_pathway.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.7 on 2026-08-17 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0017_remove_profile_dark_theme_profile_is_allowed_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Pathway', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start', models.DateTimeField()), + ('end', models.DateTimeField()), + ('name', models.CharField(max_length=200)), + ('min_mins', models.IntegerField(default=300)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/twisted/twisted_site/migrations/0019_remove_projectship_status_projectship_final_message_and_more.py b/twisted/twisted_site/migrations/0019_remove_projectship_status_projectship_final_message_and_more.py new file mode 100644 index 0000000..d8c0efb --- /dev/null +++ b/twisted/twisted_site/migrations/0019_remove_projectship_status_projectship_final_message_and_more.py @@ -0,0 +1,57 @@ +# Generated by Django 6.0.7 on 2026-08-28 15:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0018_pathway'), + ] + + operations = [ + migrations.RemoveField( + model_name='projectship', + name='status', + ), + migrations.AddField( + model_name='projectship', + name='final_message', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='projectship', + name='final_status', + field=models.CharField(choices=[('pending', 'Awaiting review'), ('requested_changes', 'Changes Requested'), ('rejected', 'Rejected'), ('approved', 'Approved')], default='pending', max_length=200), + ), + migrations.AddField( + model_name='projectship', + name='final_updated_at', + field=models.DateTimeField(default=None, null=True), + ), + migrations.AddField( + model_name='projectship', + name='fraud_message', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='projectship', + name='fraud_status', + field=models.CharField(choices=[('pending', 'Awaiting review'), ('requested_changes', 'Changes Requested'), ('rejected', 'Rejected'), ('approved', 'Approved')], default='pending', max_length=200), + ), + migrations.AddField( + model_name='projectship', + name='fraud_updated_at', + field=models.DateTimeField(default=None, null=True), + ), + migrations.AddField( + model_name='projectship', + name='t1_status', + field=models.CharField(choices=[('pending', 'Awaiting review'), ('requested_changes', 'Changes Requested'), ('rejected', 'Rejected'), ('approved', 'Approved')], default='pending', max_length=200), + ), + migrations.AddField( + model_name='projectship', + name='t2_status', + field=models.CharField(choices=[('pending', 'Awaiting review'), ('requested_changes', 'Changes Requested'), ('rejected', 'Rejected'), ('approved', 'Approved')], default='pending', max_length=200), + ), + ] diff --git a/twisted/twisted_site/migrations/0020_profile_twists.py b/twisted/twisted_site/migrations/0020_profile_twists.py new file mode 100644 index 0000000..fd7fe54 --- /dev/null +++ b/twisted/twisted_site/migrations/0020_profile_twists.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-08-31 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0019_remove_projectship_status_projectship_final_message_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='twists', + field=models.IntegerField(default=0), + ), + ] diff --git a/twisted/twisted_site/migrations/0021_profile_my_referral_code_profile_referred_by.py b/twisted/twisted_site/migrations/0021_profile_my_referral_code_profile_referred_by.py new file mode 100644 index 0000000..83e9e3b --- /dev/null +++ b/twisted/twisted_site/migrations/0021_profile_my_referral_code_profile_referred_by.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.7 on 2026-08-31 16:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0020_profile_twists'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='my_referral_code', + field=models.CharField(blank=True, default='', max_length=200), + ), + migrations.AddField( + model_name='profile', + name='referred_by', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='twisted_site.profile'), + ), + ] diff --git a/twisted/twisted_site/migrations/0022_alter_profile_referred_by.py b/twisted/twisted_site/migrations/0022_alter_profile_referred_by.py new file mode 100644 index 0000000..b378f21 --- /dev/null +++ b/twisted/twisted_site/migrations/0022_alter_profile_referred_by.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.7 on 2026-08-31 16:18 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0021_profile_my_referral_code_profile_referred_by'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='referred_by', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='referrals', to='twisted_site.profile'), + ), + ] diff --git a/twisted/twisted_site/migrations/0023_auditlog.py b/twisted/twisted_site/migrations/0023_auditlog.py new file mode 100644 index 0000000..5cf5e6c --- /dev/null +++ b/twisted/twisted_site/migrations/0023_auditlog.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.7 on 2026-09-01 08:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0022_alter_profile_referred_by'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('path', models.CharField(max_length=400)), + ('post', models.BooleanField()), + ('pii', models.BooleanField(default=False)), + ('additional_context', models.JSONField(default={})), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='audit_logs', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/twisted/twisted_site/migrations/0024_auditlog_timestamp.py b/twisted/twisted_site/migrations/0024_auditlog_timestamp.py new file mode 100644 index 0000000..e21ce8c --- /dev/null +++ b/twisted/twisted_site/migrations/0024_auditlog_timestamp.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.7 on 2026-09-01 08:36 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0023_auditlog'), + ] + + operations = [ + migrations.AddField( + model_name='auditlog', + name='timestamp', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/twisted/twisted_site/migrations/0025_alter_auditlog_additional_context.py b/twisted/twisted_site/migrations/0025_alter_auditlog_additional_context.py new file mode 100644 index 0000000..4df221c --- /dev/null +++ b/twisted/twisted_site/migrations/0025_alter_auditlog_additional_context.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-01 10:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0024_auditlog_timestamp'), + ] + + operations = [ + migrations.AlterField( + model_name='auditlog', + name='additional_context', + field=models.JSONField(default=None, null=True), + ), + ] diff --git a/twisted/twisted_site/migrations/0026_project_playable_url.py b/twisted/twisted_site/migrations/0026_project_playable_url.py new file mode 100644 index 0000000..32151ad --- /dev/null +++ b/twisted/twisted_site/migrations/0026_project_playable_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-09-03 15:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0025_alter_auditlog_additional_context'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='playable_url', + field=models.CharField(blank=True, default='', max_length=200), + ), + ] diff --git a/twisted/twisted_site/migrations/0027_profile_hca_access_token_project_screenshot_url.py b/twisted/twisted_site/migrations/0027_profile_hca_access_token_project_screenshot_url.py new file mode 100644 index 0000000..d88875c --- /dev/null +++ b/twisted/twisted_site/migrations/0027_profile_hca_access_token_project_screenshot_url.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.7 on 2026-09-04 02:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0026_project_playable_url'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='hca_access_token', + field=models.CharField(blank=True, default='', max_length=2000), + ), + migrations.AddField( + model_name='project', + name='screenshot_url', + field=models.CharField(blank=True, default='', max_length=500), + ), + ] diff --git a/twisted/twisted_site/migrations/0028_remove_projectship_final_message_and_more.py b/twisted/twisted_site/migrations/0028_remove_projectship_final_message_and_more.py new file mode 100644 index 0000000..de3123b --- /dev/null +++ b/twisted/twisted_site/migrations/0028_remove_projectship_final_message_and_more.py @@ -0,0 +1,86 @@ +# Generated by Django 6.0.7 on 2026-09-05 05:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0027_profile_hca_access_token_project_screenshot_url'), + ] + + operations = [ + migrations.RemoveField( + model_name='projectship', + name='final_message', + ), + migrations.RemoveField( + model_name='projectship', + name='final_status', + ), + migrations.RemoveField( + model_name='projectship', + name='final_updated_at', + ), + migrations.RemoveField( + model_name='projectship', + name='fraud_message', + ), + migrations.RemoveField( + model_name='projectship', + name='fraud_status', + ), + migrations.RemoveField( + model_name='projectship', + name='fraud_updated_at', + ), + migrations.RemoveField( + model_name='projectship', + name='t1_message', + ), + migrations.RemoveField( + model_name='projectship', + name='t1_status', + ), + migrations.RemoveField( + model_name='projectship', + name='t1_updated_at', + ), + migrations.RemoveField( + model_name='projectship', + name='t2_message', + ), + migrations.RemoveField( + model_name='projectship', + name='t2_status', + ), + migrations.RemoveField( + model_name='projectship', + name='t2_updated_at', + ), + migrations.AddField( + model_name='projectship', + name='audit_note', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='projectship', + name='deflation_reason', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='projectship', + name='note_to_maker', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='projectship', + name='status', + field=models.CharField(choices=[('pending', 'Awaiting review'), ('requested_changes', 'Changes Requested'), ('rejected', 'Rejected'), ('approved', 'Approved')], default='pending', max_length=200), + ), + migrations.AddField( + model_name='projectship', + name='technical_features', + field=models.CharField(blank=True, default='', max_length=255), + ), + ] diff --git a/twisted/twisted_site/migrations/0029_projectship_final_audit_note_and_more.py b/twisted/twisted_site/migrations/0029_projectship_final_audit_note_and_more.py new file mode 100644 index 0000000..d16c85c --- /dev/null +++ b/twisted/twisted_site/migrations/0029_projectship_final_audit_note_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.7 on 2026-09-05 06:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0028_remove_projectship_final_message_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='projectship', + name='final_audit_note', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='projectship', + name='final_note_to_maker', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='projectship', + name='final_status', + field=models.CharField(choices=[('pending', 'Awaiting review'), ('requested_changes', 'Changes Requested'), ('rejected', 'Rejected'), ('approved', 'Approved')], default='pending', max_length=200), + ), + ] diff --git a/twisted/twisted_site/migrations/0030_alter_journal_minutes_worked_and_more.py b/twisted/twisted_site/migrations/0030_alter_journal_minutes_worked_and_more.py new file mode 100644 index 0000000..2405650 --- /dev/null +++ b/twisted/twisted_site/migrations/0030_alter_journal_minutes_worked_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.7 on 2026-09-05 12:54 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twisted_site', '0029_projectship_final_audit_note_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='journal', + name='minutes_worked', + field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]), + ), + migrations.AlterField( + model_name='journal', + name='reduced_minutes', + field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]), + ), + ] diff --git a/twisted/twisted_site/models.py b/twisted/twisted_site/models.py index de49a36..1941cb7 100644 --- a/twisted/twisted_site/models.py +++ b/twisted/twisted_site/models.py @@ -1,38 +1,94 @@ from django.db.models import TextField from django.contrib.auth import get_user_model +from django.core.validators import MinValueValidator from django.db import models +from django.utils import timezone from . import hackatime +from .slack import slack_bot User = get_user_model() +JOURNAL_TYPES = { + "hackatime": "Hackatime", + "lookout": "Lookout", + "untracked": "Untracked", +} + + +class UploadedFile(models.Model): + uploaded_by = models.ForeignKey(User, on_delete=models.PROTECT) + link = models.CharField(max_length=500) + cdn_response = models.JSONField() + uploaded_thru = models.CharField(max_length=500) + filesize = models.IntegerField() + + def __str__(self): + return f"{self.cdn_response['filename']} uploaded by {self.uploaded_by.profile.slack_username}" + # Create your models here. class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") verification_status = models.CharField(max_length=64, blank=True, default="") + ysws_eligible = models.BooleanField(default=False) slack_id = models.CharField(max_length=64, blank=True, default="") slack_username = models.CharField(max_length=64, blank=True, default="") slack_pfp_url = models.CharField(max_length=200, blank=True, default="") hackatime_access_token = models.CharField(max_length=2000, blank=True, default="") hackatime_state = models.CharField(max_length=100, blank=True, default="") + + hca_access_token = models.CharField(max_length=2000, blank=True, default="") + is_staff = models.BooleanField(default=False) + is_allowed = models.BooleanField(default=False) + + twists = models.IntegerField(default=0) + + referred_by = models.ForeignKey('twisted_site.Profile', on_delete=models.PROTECT, null=True, default=None, related_name="referrals") + my_referral_code = models.CharField(max_length=200, blank=True, default="") + + def shipped_projects(self): + shipped_projects = [] + for project in self.user.projects.all(): + if project.is_shipped(): + shipped_projects.append(project) + return shipped_projects + + def time_logged(self): + time_logged = 0 + for project in self.user.projects.all(): + time_logged += project.time_logged() + return time_logged + + def time_shipped(self): + time_shipped = 0 + for project in self.shipped_projects(): + time_shipped += project.time_logged() + return time_shipped + def __str__(self): return self.user.username # ty:ignore[unresolved-attribute] +PROJECT_TYPE_CHOICES = {"software": "Software", "hardware": "Hardware"} + + class Project(models.Model): user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="projects") project_name = models.CharField(max_length=50) project_description = models.TextField(max_length=2000) - - project_type = models.CharField(choices={'software': 'Software', 'hardware': 'Hardware'}, max_length=100) + + project_type = models.CharField(choices=PROJECT_TYPE_CHOICES, max_length=100) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - + hackatime_project_name = models.CharField(max_length=200, blank=True, default="") + repo_url = models.CharField(max_length=200, blank=True, default="") + playable_url = models.CharField(max_length=200, blank=True, default="") + screenshot_url = models.CharField(max_length=500, blank=True, default="") def __str__(self): return self.project_name @@ -45,39 +101,240 @@ def get_hackatime_project(self) -> hackatime.HackatimeProject | None: if project.name == self.hackatime_project_name: return project return - + def time_logged(self, include_all_minutes=False): minutes = 0 for journal in self.journals.all(): # ty:ignore[unresolved-attribute] - # django-orm-lens-disable-next-line DOL007 if include_all_minutes: + # django-orm-lens-disable-next-line DOL007 minutes += journal.minutes_worked else: minutes += journal.reduced_minutes return minutes - - def all_time_logged(self): - return self.time_logged(True) - + + def hackatime_logged(self, include_all_minutes=False): + minutes = 0 + for journal in self.journals.all(): # ty:ignore[unresolved-attribute] + if journal.type != "hackatime": + continue + if include_all_minutes: + # django-orm-lens-disable-next-line DOL007 + minutes += journal.minutes_worked + else: + minutes += journal.reduced_minutes + return minutes + def time_spent(self): project = self.get_hackatime_project() if project is None: return 0 return project.total_seconds // 60 - def time_unjournaled(self): - return self.time_spent() - self.time_logged(include_all_minutes=True) + def hackatime_time_unjournaled(self): + return self.time_spent() - self.hackatime_logged(include_all_minutes=True) -class Journal(models.Model): - project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="journals") + def latest_ship(self): + ship = self.ships.order_by('-created_at').first() + return ship + def is_shipped(self): + latest_ship = self.latest_ship() + if latest_ship is None: + return False + return latest_ship.status != 'requested_changes' + + def is_approved(self): + latest_ship = self.latest_ship() + if latest_ship is None: + return False + return latest_ship.status == 'approved' + + +class Journal(models.Model): + project = models.ForeignKey( + Project, on_delete=models.PROTECT, related_name="journals" + ) + type = models.CharField(max_length=100, choices=JOURNAL_TYPES) + content = TextField() - + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - minutes_worked = models.IntegerField() - reduced_minutes = models.IntegerField() + minutes_worked = models.IntegerField(validators=[MinValueValidator(0)]) + reduced_minutes = models.IntegerField(validators=[MinValueValidator(0)]) + + def __str__(self): + return f"{self.reduced_minutes} mins on {self.project}" + + +PROJECT_SHIP_STATUSES = { + "pending": "Awaiting review", + "requested_changes": "Changes Requested", + "rejected": "Rejected", + "approved": "Approved", +} + + +class ProjectShip(models.Model): + project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="ships") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + status = models.CharField(default="pending", choices=PROJECT_SHIP_STATUSES, max_length=200) + + note_to_maker = models.TextField(blank=True, default="") + audit_note = models.TextField(blank=True, default="") + technical_features = models.CharField(blank=True, default="", max_length=255) + deflation_reason = models.CharField(blank=True, default="", max_length=255) + + final_status = models.CharField(default="pending", choices=PROJECT_SHIP_STATUSES, max_length=200) + final_note_to_maker = models.TextField(blank=True, default="") + final_audit_note = models.TextField(blank=True, default="") + + def __str__(self): + return f"Ship created at {self.created_at} ({self.get_status_display()})" # ty:ignore[unresolved-attribute] + + +class Pathway(models.Model): + start = models.DateTimeField() + end = models.DateTimeField() + + name = models.CharField(max_length=200) + min_mins = models.IntegerField(default=300) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def ended(self): + return timezone.now() > self.end + + def didnt_start(self): + return self.start > timezone.now() + + def in_progress(self): + return not self.ended() and not self.didnt_start() + + def status(self): + if self.ended(): + return 'ended' + if self.didnt_start(): + return 'awaiting' + if self.in_progress(): + return 'in progress' + + def mins_spent(self, user: User): + pathways = Pathway.objects.order_by('start').values('id', 'start', 'end', 'min_mins') + if not pathways: + return 0 + + pathway_totals = {p['id']: 0 for p in pathways} + + journals = Journal.objects.filter( + project__user=user + ).order_by('created_at').values_list('created_at', 'reduced_minutes') + + for j_created, j_mins in journals: + mins_remaining = j_mins + for pathway in pathways: + if mins_remaining <= 0: + break + + # Check if journal falls within the pathway window + if pathway['start'] > j_created or pathway['end'] < j_created: + continue + + p_id = pathway['id'] + mins_completed = pathway_totals.get(p_id, 0) + mins_required = pathway['min_mins'] + + if mins_completed >= mins_required: + continue + + mins_needed = mins_required - mins_completed + mins_donated = min(mins_remaining, mins_needed) + + mins_remaining -= mins_donated + pathway_totals[p_id] = mins_completed + mins_donated + + return pathway_totals[self.id] + + 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} + """ + # Fetch all pathways to accurately model the sequential time donation + pathways = list(Pathway.objects.order_by('start').values('id', 'start', 'end', 'min_mins')) + if not pathways: + 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' + ) + + user_pathway_totals = {} + + 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} + + pathway_totals = user_pathway_totals[user_id] + mins_remaining = j_mins + + for pathway in pathways: + if mins_remaining <= 0: + break + + if pathway['start'] > j_created or pathway['end'] < j_created: + continue + + p_id = pathway['id'] + mins_completed = pathway_totals[p_id] + mins_required = pathway['min_mins'] + + if mins_completed >= mins_required: + continue + + mins_needed = mins_required - mins_completed + mins_donated = min(mins_remaining, mins_needed) + + mins_remaining -= mins_donated + pathway_totals[p_id] += mins_donated + + # Extract only this pathway's result for each participant + return { + user_id: totals.get(self.id, 0) + for user_id, totals in user_pathway_totals.items() + } + + def qualified_participants(self): + per_part = self.mins_spent_per_participant() + qualified = [] + for userid, mins in per_part.items(): + if mins >= self.min_mins: + qualified.append(User.objects.get(id=userid)) + return qualified + + def __str__(self): + return self.name + +class AuditLog(models.Model): + timestamp = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User, on_delete=models.PROTECT, related_name='audit_logs') + path = models.CharField(max_length=400) + post = models.BooleanField() + pii = models.BooleanField(default=False) + + additional_context = models.JSONField(null=True, default=None) def __str__(self): - return f"{self.minutes_worked} mins on {self.project}" \ No newline at end of file + return f"Audit log for {self.user.profile.slack_username}. PII: {self.pii}" diff --git a/twisted/twisted_site/slack.py b/twisted/twisted_site/slack.py index 389c38a..4238e0d 100644 --- a/twisted/twisted_site/slack.py +++ b/twisted/twisted_site/slack.py @@ -5,95 +5,186 @@ class SlackBot: - def __init__(self, token: str | None = None, cc_group_id: str | None = None): - slack_token = token - self.client = WebClient(token=slack_token) - self._cc_group_id = cc_group_id - - def post_message( - self, - *, - channel: str, - text: str, - blocks: list[dict[str, Any]] | None = None, - **kwargs: Any, - ): - payload: dict[str, Any] = { - "channel": channel, - "text": text, - **kwargs, - } - if blocks is not None: - payload["blocks"] = blocks - return self.client.chat_postMessage(**payload) - - def send_message(self, *, channel: str, text: str, **kwargs: Any): - return self.post_message(channel=channel, text=text, **kwargs) - - def send_blocks( - self, - *, - channel: str, - blocks: list[dict[str, Any]], - text: str = " ", - **kwargs: Any, - ): - return self.post_message(channel=channel, text=text, blocks=blocks, **kwargs) - def error_log( - self, - *, - channel: str, - error: str, - title: str | None = None, - **kwargs: Any, - ): - if title is None: - title = "Error Log" - - group_id = self._cc_group_id - mention = f"<@{group_id}>" if group_id else "" - - # Parent message - parent = self.client.chat_postMessage( - channel=channel, - text=f"{title}".strip(), - blocks=[ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"*{title}*".strip(), - }, - }, - ], - ) - - # Thread message - thread_text = f"```{error}```" - if mention: - thread_text += f"\n\nCC: {mention}" - - return self.client.chat_postMessage( - channel=channel, - thread_ts=parent["ts"], - text=thread_text, - blocks=[ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": thread_text, - }, - }, - ], - ) - - def users_info(self, *, user: str): - return self.client.users_info(user=user) + def __init__( + self, + token: str | None = None, + cc_group_id: str | None = None, + ): + self.token = token or os.getenv("SLACK_TOKEN") + + if not self.token: + raise ValueError("SLACK_TOKEN must be set") + + self.client = WebClient(token=self.token) + self._cc_group_id = cc_group_id or os.getenv("SLACK_CC_GROUP_ID") + + def post_message( + self, + *, + channel: str, + text: str, + blocks: list[dict[str, Any]] | None = None, + **kwargs: Any, + ): + payload = { + "channel": channel, + "text": text, + **kwargs, + } + + if blocks is not None: + payload["blocks"] = blocks + + return self.client.chat_postMessage(**payload) + + def send_message( + self, + *, + channel: str, + text: str, + **kwargs: Any, + ): + return self.post_message( + channel=channel, + text=text, + **kwargs, + ) + + def send_blocks( + self, + *, + channel: str, + blocks: list[dict[str, Any]], + text: str = " ", + **kwargs: Any, + ): + return self.post_message( + channel=channel, + text=text, + blocks=blocks, + **kwargs, + ) + + def dm_user( + self, + *, + user: str, + text: str, + blocks: list[dict[str, Any]] | None = None, + **kwargs: Any, + ): + """ + Send a DM to a Slack user using their U... user ID. + + No D... DM channel ID is required. + """ + return self.post_message( + channel=user, + text=text, + blocks=blocks, + **kwargs, + ) + + def dm_user_blocks( + self, + *, + user: str, + blocks: list[dict[str, Any]], + text: str = " ", + **kwargs: Any, + ): + """Send Block Kit directly to a user using their U... ID.""" + return self.dm_user( + user=user, + text=text, + blocks=blocks, + **kwargs, + ) + + def users_info(self, *, user: str): + return self.client.users_info(user=user) + + def get_user_profile(self, user: str) -> dict[str, Any]: + response = self.client.users_info(user=user) + + user_data = response["user"] + profile = user_data.get("profile", {}) + + return { + "id": user_data["id"], + "name": user_data.get("name"), + "real_name": ( + user_data.get("real_name") + or profile.get("real_name") + ), + "display_name": ( + profile.get("display_name") + or user_data.get("name") + ), + "image_24": profile.get("image_24"), + "image_32": profile.get("image_32"), + "image_48": profile.get("image_48"), + "image_72": profile.get("image_72"), + "image_192": profile.get("image_192"), + "image_512": profile.get("image_512"), + } + + def error_log( + self, + *, + channel: str, + error: str, + title: str = "Error Log", + **kwargs: Any, + ): + group_id = self._cc_group_id + mention = f"<@{group_id}>" if group_id else "" + + parent = self.client.chat_postMessage( + channel=channel, + text=title, + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{title}*", + }, + } + ], + **kwargs, + ) + + # Break up any backticks in the error text so it can't close the code + # fence early and have the remainder render as live Slack mrkdwn. + safe_error = error.replace("`", "`​") + thread_text = f"```{safe_error}```" + + if mention: + thread_text += f"\n\nCC: {mention}" + + return self.client.chat_postMessage( + channel=channel, + thread_ts=parent["ts"], + text=thread_text, + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": thread_text, + }, + } + ], + ) slack_token = os.getenv("SLACK_TOKEN") cc_group_id = os.getenv("SLACK_CC_GROUP_ID") -slack_bot = SlackBot(token=slack_token, cc_group_id=cc_group_id) -slack_client = slack_bot.client +slack_bot = SlackBot( + token=slack_token, + cc_group_id=cc_group_id, +) + +slack_client = slack_bot.client \ No newline at end of file diff --git a/twisted/twisted_site/static/css/98.override.css b/twisted/twisted_site/static/css/98.override.css deleted file mode 100644 index d2634eb..0000000 --- a/twisted/twisted_site/static/css/98.override.css +++ /dev/null @@ -1,33 +0,0 @@ -.window { - background: #8364A2 !important; - border: 2px solid !important; - border-color: #CAB3E1 #29153D #29153D #CAB3E1 !important; - box-shadow: none !important; -} - -.title-bar { - background: linear-gradient(90deg, #5A4471, #8364A2) !important; - color: #FAFAFA !important; - padding: 3px 4px !important; -} - -.title-bar-text { - color: #FAFAFA !important; - font-weight: bold; -} - -.title-bar-controls button { - background: #CAB3E1 !important; - border: 1px solid !important; - border-color: #FAFAFA #29153D #29153D #FAFAFA !important; - color: #29153D !important; - min-width: 18px; - min-height: 16px; -} - -.window-body { - background: #36214B !important; - color: #FAFAFA !important; - margin: 0 !important; - padding: 4px !important; -} \ No newline at end of file diff --git a/twisted/twisted_site/static/favicon.png b/twisted/twisted_site/static/favicon.png new file mode 100644 index 0000000..eb96aa8 Binary files /dev/null and b/twisted/twisted_site/static/favicon.png differ diff --git a/twisted/twisted_site/static/fonts/Piedra-Regular.ttf b/twisted/twisted_site/static/fonts/Piedra-Regular.ttf new file mode 100644 index 0000000..c3c75e4 Binary files /dev/null and b/twisted/twisted_site/static/fonts/Piedra-Regular.ttf differ diff --git a/twisted/twisted_site/static/fonts/VT323-Regular.ttf b/twisted/twisted_site/static/fonts/VT323-Regular.ttf new file mode 100644 index 0000000..6aec599 Binary files /dev/null and b/twisted/twisted_site/static/fonts/VT323-Regular.ttf differ diff --git a/twisted/twisted_site/static/icons/delete.png b/twisted/twisted_site/static/icons/delete.png new file mode 100644 index 0000000..e1c1a40 Binary files /dev/null and b/twisted/twisted_site/static/icons/delete.png differ diff --git a/twisted/twisted_site/static/icons/edit.png b/twisted/twisted_site/static/icons/edit.png new file mode 100644 index 0000000..875fd0e Binary files /dev/null and b/twisted/twisted_site/static/icons/edit.png differ diff --git a/twisted/twisted_site/templates/admin/announcements.html b/twisted/twisted_site/templates/admin/announcements.html new file mode 100644 index 0000000..b8ebd1b --- /dev/null +++ b/twisted/twisted_site/templates/admin/announcements.html @@ -0,0 +1,5 @@ + + Primary Button +Default Button + + \ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/dashboard.html b/twisted/twisted_site/templates/admin/dashboard.html new file mode 100644 index 0000000..b2d7928 --- /dev/null +++ b/twisted/twisted_site/templates/admin/dashboard.html @@ -0,0 +1,168 @@ + + + + + +

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

+

+ See statistics + + Manage Users + + Edit Pathways + Fulfill orders + Edit shop + Review projects + Announce stuff +

+
+ + + +
+
+
+
+ Hours logged +
+
+ {{ hours_logged }}h +
+
+
+ Loading chart... +
+
+
+ +
+
+
+
+ Project type +
+
+
+ Loading chart... +
+
+
+ +
+
+
+
+ + +
+
+
+ Hours shipped +
+
+ {{ hours_shipped }}h +
+
+
+ Loading chart... +
+
+ +
+
+
+
+ Project type +
+
+
+ Loading chart... +
+
+
+ +
+
+
+
+ + This is still WIP! (annoy @kavyansh. to fix if you see this after the YSWS has started!) + +
+
\ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/debug/review.html b/twisted/twisted_site/templates/admin/debug/review.html new file mode 100644 index 0000000..76da221 --- /dev/null +++ b/twisted/twisted_site/templates/admin/debug/review.html @@ -0,0 +1,94 @@ + +
+ DEBUG_REVIEW is set to true in .env! Please do not use this in production! + If this is a production environment, please switch DEBUG_REVIEW to false! +
+
+ {% for ship in ships %} +
+ +

+ Ship for + {{ ship.project.project_name }} +

+
+ Created {{ ship.created_at }} // + Updated {{ ship.updated_at }} +
+
+
+ {% csrf_token %} + + + + + Status + Note to maker + Audit note (internal) + Technical features (internal) + Deflation reason (internal) + + + + + + + pending + requested_changes + rejected + approved + + + + + + + + + + + + + + + + + +

Final review (manual sign-off)

+ + + + Final status + Final note to maker + Final audit note (internal) + + + + + + + pending + requested_changes + rejected + approved + + + + + + + + + + + +
+ Update +
+
+
+
+
+ {% endfor %} +
+
\ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/fulfillment.html b/twisted/twisted_site/templates/admin/fulfillment.html new file mode 100644 index 0000000..b8ebd1b --- /dev/null +++ b/twisted/twisted_site/templates/admin/fulfillment.html @@ -0,0 +1,5 @@ + + Primary Button +Default Button + + \ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/logs.html b/twisted/twisted_site/templates/admin/logs.html new file mode 100644 index 0000000..5025a1e --- /dev/null +++ b/twisted/twisted_site/templates/admin/logs.html @@ -0,0 +1,86 @@ + +
+
+

+ Audit Logs ({{ log_count }}) +

+

+ are others doing something naughty..? +

+
+ +
+
+
+
+ +
+
+
+
+ {% for log in logs %} + +
+
+ + + {{ log.user.profile.slack_username }} + +
+
+ {% if log.pii %} + PII: Yes + {% else %} + PII: No + {% endif %} +
+
+ + {{ log.timestamp }} + +
+
+
+ + {{ log.post|yesno:"POST,GET" }} + + + {{ log.path }} + +
+ {% if log.additional_context %} +
+ +
+ + {% for key, val in log.additional_context.items %} +
+ {% if key|slice:"-5:" == "__img" %} + + {{ key|slice:":-5" }} + + + {{ key }} + + {% else %} + + {{ key }} + + + {{ val }} + + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} +
+ {% endfor %} +
+
+
+ +
+
+
\ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/pathways/create.html b/twisted/twisted_site/templates/admin/pathways/create.html new file mode 100644 index 0000000..3988f17 --- /dev/null +++ b/twisted/twisted_site/templates/admin/pathways/create.html @@ -0,0 +1,44 @@ +{% load tz %} +{% get_current_timezone as TIMEZONE %} + + +
+ + Admin + Pathways + New + +

Create Pathway

+
+ {% csrf_token %} +
+ +
+
+ Start +
+ + +
+
+ +
+ End +
+ + +
+
+
+
+ Start/End is in {{ TIMEZONE }} (your local time). +
+
+ +
+ Create Pathway +
+
+
+
diff --git a/twisted/twisted_site/templates/admin/pathways/detail.html b/twisted/twisted_site/templates/admin/pathways/detail.html new file mode 100644 index 0000000..19a67ec --- /dev/null +++ b/twisted/twisted_site/templates/admin/pathways/detail.html @@ -0,0 +1,87 @@ +{% load time_filters %} + +
+ + Admin + Pathways + {{ pathway.name }} + + + +
+

{{ pathway.name }}

+ {% if pathway.in_progress %} + In progress + {% elif pathway.didnt_start %} + Not started + {% elif pathway.ended %} + Ended + {% endif %} +
+
+
+

Start

+

{{ pathway.start }}

+
+
+

End

+

{{ pathway.end }}

+
+
+

Minimum time required

+

{{ pathway.min_mins|minutes_to_hours_minutes }}

+
+
+

Qualified participants

+

{{ qualified_count }} / {{ participants|length }}

+
+
+
+ +
+

Participants

+ {% if participants %} + + + + + Participant + Time logged + Progress + Status + + + + {% for participant in participants %} + + + + + @{{ participant.user.profile.slack_username }} + + + {{ participant.mins|minutes_to_hours_minutes }} + + + + + {% if participant.qualified %} + Qualified + {% else %} + In progress + {% endif %} + + + {% endfor %} + + + + {% else %} + +

No participants yet

+

Nobody has logged time towards this pathway.

+
+ {% endif %} +
+
+
\ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/pathways/list.html b/twisted/twisted_site/templates/admin/pathways/list.html new file mode 100644 index 0000000..6e3ba4c --- /dev/null +++ b/twisted/twisted_site/templates/admin/pathways/list.html @@ -0,0 +1,54 @@ + +
+
+

+ Pathways +

+ + + + + + Create new + + +
+
+
+

+ Not started ({{ future_pathways|length }} item{{ future_pathways|pluralize }}) +

+
+ {% for pathway in future_pathways %} + + {% endfor %} +
+
+
+

+ In progress ({{ current_pathways|length }} item{{ current_pathways|pluralize }}) +

+
+ {% for pathway in current_pathways %} + + {% endfor %} +
+
+
+

+ Ended ({{ past_pathways|length }} item{{ past_pathways|pluralize }}) +

+
+ {% for pathway in past_pathways %} + + {% endfor %} +
+
+
+
+
\ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/review.html b/twisted/twisted_site/templates/admin/review.html new file mode 100644 index 0000000..b8ebd1b --- /dev/null +++ b/twisted/twisted_site/templates/admin/review.html @@ -0,0 +1,5 @@ + + Primary Button +Default Button + + \ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/shop.html b/twisted/twisted_site/templates/admin/shop.html new file mode 100644 index 0000000..b8ebd1b --- /dev/null +++ b/twisted/twisted_site/templates/admin/shop.html @@ -0,0 +1,5 @@ + + Primary Button +Default Button + + \ No newline at end of file diff --git a/twisted/twisted_site/templates/admin/user.html b/twisted/twisted_site/templates/admin/user.html new file mode 100644 index 0000000..36a99ab --- /dev/null +++ b/twisted/twisted_site/templates/admin/user.html @@ -0,0 +1,73 @@ +{% load maths time_filters %} + + + Back + + +
+ +
+

+ @{{ user.profile.slack_username }} + {{ user.profile.slack_id|default:"user not found" }} +

+ {% if user.profile %} +
+ {% if user.profile.is_staff %} + Staff + {% endif %} + {% if login_maybe %} + {{ user.profile.is_allowed|yesno:"Login allowed,Login disallowed" }} + {% endif %} + {{ user.profile.ysws_eligible|yesno:"YSWS eligible,Not YSWS eligible" }} +
+ {% endif %} +
+
+
+
+

Projects

+ {% if user.projects.all %} +
+ {% for project in user.projects.all %} + +
+

{{ project }}

+
+
+ {{ project.get_project_type_display }} + {% if project.latest_ship %} + {{ project.latest_ship.get_status_display }} + {% endif %} +
+
+

+ {{ project.time_logged|minutes_to_hours_minutes }} logged +

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

+ No projects found :( +

+

+ This user has not created any projects! +

+
+ {% endif %} +
+ {% if login_maybe %} +
+
+ {% csrf_token %} + + Toggle allowed state +
+
+ {% endif %} +
diff --git a/twisted/twisted_site/templates/admin/users.html b/twisted/twisted_site/templates/admin/users.html new file mode 100644 index 0000000..ae0d8d4 --- /dev/null +++ b/twisted/twisted_site/templates/admin/users.html @@ -0,0 +1,92 @@ +{% load maths %} + +
+
+ + + + + + + +
+
+ +
+
+ +
+
+ {% csrf_token %} + + hehe click this funny button to log everyone out: + Log everyone out +
+
+
diff --git a/twisted/twisted_site/templates/client/dashboard.html b/twisted/twisted_site/templates/client/dashboard.html index 89f2c3f..8dbf98e 100644 --- a/twisted/twisted_site/templates/client/dashboard.html +++ b/twisted/twisted_site/templates/client/dashboard.html @@ -1,218 +1,468 @@ {% extends "client/osbase.html" %} {% load static %} {% block body %} -
-
- -
- -
- -
-
- - -