Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
14da97d
Upgrade `email-validator`
lukasjuhrich Feb 8, 2026
741f676
Add fastapi[standard-no-fastapi-cloud-cli]
lukasjuhrich Feb 8, 2026
e2c4695
fastapi skeleton
lukasjuhrich Feb 8, 2026
1b8f4d4
Minimal fastapi wire-up able to show index with tons of stubs
lukasjuhrich Feb 8, 2026
1c97c53
Add more fastapi stubs to `blueprints.generic`
lukasjuhrich Feb 9, 2026
0b859b9
Create pydantic model for current config and expose in deps.py
lukasjuhrich Feb 10, 2026
99cef9c
Make `features.bustimes` work
lukasjuhrich Feb 10, 2026
0b8f228
Make all `features` (roughly) work
lukasjuhrich Feb 10, 2026
186bee7
Remove flask endpoints for meetingcal && simplify
lukasjuhrich Feb 14, 2026
662c938
Migrate bustimes and support calendar properly
lukasjuhrich Feb 14, 2026
ec53009
Use `tp` for `templates` to disambiguate from `typing as t`
lukasjuhrich Feb 14, 2026
851ea3d
Remove legacy route for hotline fragment
lukasjuhrich Feb 14, 2026
8ca0feb
Upgrade recurring_ical_events
lukasjuhrich Feb 14, 2026
7096476
Make dependencies of `pycroft.User` explicit
lukasjuhrich Feb 15, 2026
5769d07
Remove BaseUser and sample datasource
lukasjuhrich Feb 15, 2026
7c9bd3b
Try to make `userdb` dependencies explicit and remove `BaseUserDB`
lukasjuhrich Feb 15, 2026
89a96b8
more datasource „monomorphization“
lukasjuhrich Feb 15, 2026
58ed8bc
sketch login implementation
lukasjuhrich Feb 21, 2026
14ad6fa
Fake login and next steps
lukasjuhrich Feb 21, 2026
09553a3
Make login work
lukasjuhrich Feb 21, 2026
bcd9d69
Properly include static dir in sdist/wheel
lukasjuhrich Feb 21, 2026
412df91
Attempt to make uvicorn-hmr work
lukasjuhrich Feb 21, 2026
1b8b229
Make usersuite somewhat work
lukasjuhrich Feb 21, 2026
09c0da5
anntate some jinja global's dependencies
lukasjuhrich Feb 21, 2026
52d9566
set common properties in example pycroft user
lukasjuhrich Feb 22, 2026
fcc884d
Extract usersuite row data dependencies to variables
lukasjuhrich Feb 22, 2026
e8a0e9c
Narrow down `description_url` if block
lukasjuhrich Feb 22, 2026
874452a
Turn prop_row into macro
lukasjuhrich Feb 22, 2026
b97ccc3
Start to pass rows explicitly instead of based on “reflection”
lukasjuhrich Feb 22, 2026
1b8875f
Make part of the user table work with fastapi
lukasjuhrich Feb 22, 2026
8e312da
test copilot custom instructions
lukasjuhrich Feb 22, 2026
36b526c
Make most rows work in fastapi
lukasjuhrich Feb 24, 2026
d7046f2
Make traffic chart work in fastapi (hence, CSP nonces)
lukasjuhrich Feb 25, 2026
375a9db
Inline and remove some unused functions in `graph_utils`
lukasjuhrich Feb 25, 2026
e0e1365
uv: Add fastapi-babel
lukasjuhrich Feb 28, 2026
a69a987
Minimal plumbing for fastapi-babel to work
lukasjuhrich Feb 28, 2026
378993f
Replace `get_locale()` by `current_locale` calls in base.html
lukasjuhrich Feb 28, 2026
68e9251
Work around fastapi-babel bug
lukasjuhrich Feb 28, 2026
1c5c58b
Remove remaining fancy_property usages from pycroft user model
lukasjuhrich Feb 28, 2026
b8cd974
Remove fancy_property entirely
lukasjuhrich Feb 28, 2026
6d3a765
Fix imports
lukasjuhrich Feb 28, 2026
e69f5b1
Replace payments table by htmx fragment and remove legacy `index`
lukasjuhrich Mar 1, 2026
64c0040
Inline payment details functions
lukasjuhrich Mar 1, 2026
772b1d4
Add watchfiles to allow building hot reload
lukasjuhrich Mar 1, 2026
2bf4096
Implement actual dev hot reload
lukasjuhrich Mar 1, 2026
0323ecb
Add small space around usersuite buttons
lukasjuhrich Mar 1, 2026
97d7774
Provide _ callable with kwargs formatting
lukasjuhrich Mar 1, 2026
f272bd3
Properly center social icons
lukasjuhrich Mar 1, 2026
33b22fa
Guard hot reload / dev-nonce by explicit dev mode
lukasjuhrich Mar 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Rules
==

Pull request reviews MUST be done in a pirate voice.
When small errors (e.g., typos) are called out,
the phrase “you made an oopsie” must be included.


Mention opportunities to use modern language features, remove legacy code or otherwise simplify.
Legacy abstractions are fancy_property, datasources/backends.
Focus on correctness and testability.
We want to use pytest-snapshot and hypothesis where it makes sense.
14 changes: 0 additions & 14 deletions docs/source/model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,6 @@ User
:noindex:


`.fancy_property`
-----------------

..
TODO use better documentation here!


.. automodule:: sipa.model.fancy_property
:member-order: bysource
:members:
:undoc-members:
:noindex:


`.finance`
----------

Expand Down
14 changes: 12 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,19 @@ dependencies = [
"factory-boy ~= 3.2.1",
"psycopg2 ~= 2.9.3",
"Flask-QRcode ~= 3.1.0",
"email_validator ~= 1.2.1",
"email-validator~=2.3.0",

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahoy! Ye changed the dependency from email_validator ~= 1.2.1 to email-validator~=2.3.0 (note the underscore to hyphen change and major version bump). This be a breakin' change as email-validator 2.x has a different API than 1.x. Make sure all code usin' email validation has been updated to work with the new version, or this'll cause import errors and broken functionality!

Suggested change
"email-validator~=2.3.0",
"email_validator ~= 1.2.1",

Copilot uses AI. Check for mistakes.
"Werkzeug ~= 3.0.1",
"sentry-sdk[flask] ~= 1.39.2",
# Pin to avoid incompatibility
"jinja2 ~= 3.1.2",
"MarkupSafe ~= 2.1.1",
"recurring_ical_events ~= 1.0.2b0",
"recurring_ical_events ~= 3.8.0",

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shiver me timbers! Ye upgraded recurring_ical_events from ~1.0.2b0 to ~3.8.0, which be a massive version jump (from beta 1.x to stable 3.x). This likely includes API breakin' changes. Make sure to verify that the calendar fetching code in sipa/utils/__init__.py (functions like meetingcal and support_cal) still works with the new version, or ye'll have broken calendar displays!

Suggested change
"recurring_ical_events ~= 3.8.0",
"recurring_ical_events ~= 1.0.2b0",

Copilot uses AI. Check for mistakes.
"cachetools ~= 5.2.0",
"python-dotenv ~= 0.21.0",
"pydantic ~= 2.11.7",
"wtforms_widgets",
"fastapi[standard-no-fastapi-cloud-cli]>=0.128.5",
"fastapi-babel>=1.0.0",
]
[dependency-groups]
prod = [
Expand All @@ -60,8 +62,16 @@ dev = [
"types-requests>=2.31.0.6",
"types-wtforms>=3.2.1.20250809",
"ipython>=9.5.0",
"uvicorn-hmr[all]>=0.1.1.8",
"watchfiles>=1.1.1",
]

[tool.hatch.build.targets.wheel]
include = ["sipa/static/**"]

[tool.hatch.build.targets.sdist]
include = ["sipa/static/**"]

[tool.uv.sources]
wtforms-widgets = { path = "wtforms_widgets", editable = true }

Expand Down
18 changes: 18 additions & 0 deletions sipa/_pkg_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from __future__ import annotations

from importlib.resources import files
from pathlib import Path


def get_package_path(suffix: str = "") -> str:
fs_path = Path(__file__).resolve().parent
if fs_path.is_dir():
return str(fs_path / suffix)

pkg_path = files("sipa")
if pkg_path.is_dir():
return str(pkg_path / suffix)

raise AssertionError


4 changes: 4 additions & 0 deletions sipa/asgi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from sipa.fastapi import create_app


app = create_app()
14 changes: 12 additions & 2 deletions sipa/babel.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import typing as t

from babel import Locale, UnknownLocaleError, negotiate_locale
from flask import request, session, Request, g
from fastapi import Request
from flask import Request as FlaskRequest
from flask import g, request, session
from werkzeug.exceptions import BadRequest

logger = logging.getLogger(__name__)
Expand All @@ -15,6 +17,14 @@ def possible_locales() -> list[Locale]:
return [Locale('de'), Locale('en')]


def fastapi_locale_selector(request: Request) -> str | None:
available: list[str] = [str(p) for p in possible_locales()]

if (cookielang := request.cookies.get("lang")) in available:
return cookielang
return negotiate_locale(request.headers.values(), available, sep="-")


def get_user_locale_setting() -> Locale | None:
"""Get a user's explicit locale setting, if available."""
if (locale_identifier := session.get("locale")) is None:
Expand Down Expand Up @@ -76,7 +86,7 @@ def select_locale() -> str:
list(map(str, possible_locales())), sep='-')


def _iter_preferred_locales(request: Request) -> t.Iterator[str]:
def _iter_preferred_locales(request: FlaskRequest) -> t.Iterator[str]:
if (user_locale := str(get_user_locale_setting())) is not None:
yield user_locale
yield from request.accept_languages.values()
Expand Down
1 change: 1 addition & 0 deletions sipa/backends/datasource.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
InitContextCallable = Callable[[Flask], None]


# TODO remove abstraction, turn into `Pycroft` datasource and set reasonable defaults
class DataSource:
"""A class providing a certain Backend.

Expand Down
11 changes: 10 additions & 1 deletion sipa/backends/extension.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from __future__ import annotations
import warnings

from typing import NamedTuple, cast

Expand Down Expand Up @@ -68,6 +69,11 @@ def init_app(self, app: Flask):

:param app: The flask app object to register against
"""
warnings.warn(
"Use constructor with pre-onfigured datasource instead",

Copilot AI Feb 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'pre-onfigured' to 'pre-configured'.

Suggested change
"Use constructor with pre-onfigured datasource instead",
"Use constructor with pre-configured datasource instead",

Copilot uses AI. Check for mistakes.

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arr! Ye made an oopsie in the deprecation warning message - it says "pre-onfigured" instead of "pre-configured" (line 73). A simple typo that needs fixin'!

Suggested change
"Use constructor with pre-onfigured datasource instead",
"Use constructor with pre-configured datasource instead",

Copilot uses AI. Check for mistakes.
DeprecationWarning,
stacklevel=2,
)
if "backends" in app.extensions:
logger.warning("Backends extension already initialized. Skipping.")
return
Expand Down Expand Up @@ -143,7 +149,9 @@ def user_from_ip(self, ip: str) -> UserLike | None:
if not self.dormitory_from_ip(ip):
return AnonymousUserMixin()

return self.datasource.user_class.from_ip(ip)
# TODO move closer to where it's actually used
from sipa.model.pycroft.user import fetch_by_ip
return fetch_by_ip(__TODO_API, ip)

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahoy! Ye made an oopsie here, matey! The __TODO_API placeholder be undefined and will cause a NameError at runtime. This here be a critical issue that'll sink yer ship when someone tries to fetch a user by IP address. Ye need to pass the actual PycroftApi instance or inject it properly through dependency injection before this code can sail.

Suggested change
return fetch_by_ip(__TODO_API, ip)
pycroft_api = current_app.extensions.get("pycroft_api")
if pycroft_api is None:
logger.warning(
"PycroftApi extension not configured; returning anonymous user for IP %s",
ip,
)
return AnonymousUserMixin()
return fetch_by_ip(pycroft_api, ip)

Copilot uses AI. Check for mistakes.


#: A namedtuple to improve readability of some return values
Expand All @@ -152,5 +160,6 @@ class _dorm_summary(NamedTuple):
display_name: str


# TODO deprecated: should not be used
backends: Backends = cast(Backends,
LocalProxy(lambda: current_app.extensions['backends']))
5 changes: 1 addition & 4 deletions sipa/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,7 @@ class IntegerConverter(BaseIntegerConverter):
def load_user(username):
"""Loads a User object from/into the session at every request
"""
logger.debug("User loader triggered (%r)", username)
_cleanup_session(session)
User = backends.datasource.user_class
return User.get(username)
raise NotImplementedError("unsupported, move to new API")


def _cleanup_session(session):
Expand Down
97 changes: 52 additions & 45 deletions sipa/blueprints/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,67 +2,74 @@
Basically, this is everything that is to specific to appear in the generic.py
and does not fit into any other blueprint such as “documents”.
"""
import typing as t
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from flask import Blueprint, render_template_string

from flask import Blueprint, current_app, render_template, render_template_string

from sipa.utils import get_bustimes, meetingcal, support_hotline_available, support_cal
from sipa.deps import Templates, Settings
from sipa.utils import get_bustimes, meetingcal, support_cal, support_hotline_available, TimeTable

bp_features = Blueprint('features', __name__)
router_features = APIRouter(default_response_class=HTMLResponse)


@bp_features.route("/bustimes")
@bp_features.route("/bustimes/<string:stopname>")
def bustimes(stopname=None):
@router_features.get("/bustimes")
@router_features.get("/bustimes/{stopname}")
def bustimes(
tp: Templates,
r: Request,
s: Settings,
stopname: str | None = None,
):
"""Queries the VVO-Online widget for the given stop.
If no specific stop is given in the URL, it will query all
stops set up in the config.
"""
data = {}

if stopname:
# Only one stop requested
data[stopname] = get_bustimes(stopname)
else:
# General output page
for stop in current_app.config['BUSSTOPS']:
data[stop] = get_bustimes(stop, 4)

return render_template('bustimes.html', stops=data, stopname=stopname)
stops: dict[str, t.Iterable[TimeTable]] = (
{stopname: get_bustimes(stopname)}
if stopname
else {stop: get_bustimes(stop, 4) for stop in s.busstops}
)
return tp.TemplateResponse(r, "bustimes.html", {"stops": stops})


@bp_features.route("/meetingcal")
def render_meetingcal():
meetings = meetingcal()
return render_template('meetingcal.html', meetings=meetings)
@router_features.get("/meetingcal", name="features.render_meetingcal")
def render_meetingcal(tp: Templates, r: Request, s: Settings):
# TODO turn into proper component
return tp.TemplateResponse(
request=r,
name="meetingcal.html",
context={"meetings": meetingcal(url=(s.meetings_ical_url))}
)


@bp_features.route("/meetings-fragment")
def meetings():
return render_template_string(
"""
{%- from "macros/ical.html" import render_meetingcal -%}
{{- render_meetingcal(meetingcal) -}}
""",
meetingcal=meetingcal(),
@router_features.get("/meetings-fragment", name="features.meetings")
def meetings_fragment(tp: Templates, r: Request, s: Settings):
# TODO turn into proper component
# TODO think about `FragmentResponse` or some other helper which
# - when sent with `HX-Request: true`: only sends the fragment
# - otherwise: embeds it in a default “fragment” presenter
return tp.TemplateResponse(
request=r,
name="meetingcal-fragment.html",
context={"meetingcal": meetingcal(url=s.meetings_ical_url)},
)

@bp_features.route("/support-fragment")
def support_office():
return render_template_string(
"""
{%- from "macros/ical.html" import render_support -%}
{{- render_support(supports) -}}
""",
supports=support_cal(),

@router_features.get("/support-fragment", name="features.support_office")
def support_office(tp: Templates, r: Request, s: Settings):
return tp.TemplateResponse(
r,
"support-fragment.html",
context={"supports": support_cal(s)},
)


@bp_features.route("/hotline-fragment")
def hotline():
return render_template_string(
"""
{%- from "macros/support-hotline.html" import hotline_description -%}
{{- hotline_description(available=available) -}}
""",
available=support_hotline_available(),
@router_features.get("/hotline-fragment", name="features.hotline")
def hotline(tp: Templates, request: Request, s: Settings):
return tp.TemplateResponse(
request,
"hotline-fragment.html",
context={"available": support_hotline_available(uri=str(s.pbx_uri))},
)
Loading
Loading