diff --git a/Dockerfile b/Dockerfile index 6941f66..2819e13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,8 @@ RUN apt-get update && \ libfribidi0 \ libharfbuzz0b \ libpng16-16 \ - libjpeg62-turbo && \ + libjpeg62-turbo \ + sqlite3 && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* @@ -43,15 +44,28 @@ RUN fc-cache -fv WORKDIR /app -# Copy application source, config, and fonts +# Copy application source, config, migrations, and fonts COPY app/ ./app/ COPY fonts/ ./fonts/ +COPY alembic/ ./alembic/ +COPY alembic.ini ./ +COPY entrypoint.sh ./ +RUN chmod +x entrypoint.sh COPY pyproject.toml run_fastapi.py ./ +# entrypoint.sh handles: +# 1. DB directory creation +# 2. Alembic stamp for existing DBs without migration tracking +# 3. Alembic upgrade head (run migrations) +# 4. Start uvicorn + ENV PYTHONPATH=/app \ DB_PATH=/app/data/churchtools.db EXPOSE 5005 VOLUME /app/data -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5005"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5005/health')" || exit 1 + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/Makefile b/Makefile index b22a7f2..c04ddda 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ -.PHONY: run test lint format build push preview +.PHONY: run run-docker test lint format build push PYTHON := venv/bin/python run: + CHURCHTOOLS_BASE=$${CHURCHTOOLS_BASE:-$$(grep -s CHURCHTOOLS_BASE .env | cut -d= -f2)} \ + $(PYTHON) -m alembic upgrade head && \ $(PYTHON) -m uvicorn app.main:app --reload --host 0.0.0.0 --port 5005 test: @@ -14,11 +16,15 @@ lint: format: $(PYTHON) -m ruff check --fix . && $(PYTHON) -m ruff format . +run-docker: + podman run --rm -p 5005:5005 -v ./data:/app/data \ + -e CHURCHTOOLS_BASE=$${CHURCHTOOLS_BASE:-$$(grep -s CHURCHTOOLS_BASE .env | cut -d= -f2)} \ + -e DB_PATH=/app/data/churchtools.db \ + churchtools-local + build: podman build -t churchtools-local . push: ./build-and-push-docker-image.sh -preview: - $(PYTHON) scripts/preview_pdf.py && open app/saved_files/*_Termine.pdf diff --git a/README.md b/README.md index 4483845..3a4fbe0 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ make run |---|---|---|---| | `CHURCHTOOLS_BASE` | Yes | — | Your ChurchTools domain (e.g. `my-church.church.tools`) | | `DB_PATH` | No | `churchtools.db` | Path to the SQLite database file | +| `TIMEZONE` | No | `Europe/Berlin` | Timezone for date display (any valid IANA timezone) | +| `LOG_FORMAT` | No | `console` | Log output format: `console` (human-readable) or `json` | ## Deployment @@ -99,7 +101,7 @@ Releases are managed via GitHub Actions: | Command | Description | |---|---| -| `make run` | Start dev server with auto-reload | +| `make run` | Run migrations and start dev server with auto-reload | | `make test` | Run test suite | | `make lint` | Check code style (ruff) | | `make format` | Auto-fix code style | diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f0da7e5 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..a2d6f2b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,41 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +import app.models # noqa: F401 — register models with Base.metadata +from alembic import context +from app.config import settings +from app.database import Base + +config = context.config +config.set_main_option("sqlalchemy.url", f"sqlite:///{settings.db_path}") + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_initial_schema.py b/alembic/versions/001_initial_schema.py new file mode 100644 index 0000000..4d77d8b --- /dev/null +++ b/alembic/versions/001_initial_schema.py @@ -0,0 +1,58 @@ +"""initial schema + +Revision ID: 001 +Revises: +Create Date: 2026-03-15 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "appointments", + sa.Column("id", sa.String(), nullable=False), + sa.Column("additional_info", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + if_not_exists=True, + ) + op.create_table( + "color_settings", + sa.Column("setting_name", sa.String(), nullable=False), + sa.Column("background_color", sa.String(), nullable=False), + sa.Column("background_alpha", sa.Integer(), nullable=False), + sa.Column("date_color", sa.String(), nullable=False), + sa.Column("description_color", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("setting_name"), + if_not_exists=True, + ) + op.create_table( + "logo_settings", + sa.Column("setting_name", sa.String(), nullable=False), + sa.Column("logo_data", sa.LargeBinary(), nullable=True), + sa.Column("logo_filename", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("setting_name"), + if_not_exists=True, + ) + op.create_table( + "background_image_settings", + sa.Column("setting_name", sa.String(), nullable=False), + sa.Column("image_data", sa.LargeBinary(), nullable=True), + sa.Column("image_filename", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("setting_name"), + if_not_exists=True, + ) + + +def downgrade() -> None: + op.drop_table("background_image_settings") + op.drop_table("logo_settings") + op.drop_table("color_settings") + op.drop_table("appointments") diff --git a/alembic/versions/__init__.py b/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/appointments.py b/app/api/appointments.py index da10504..b64c029 100644 --- a/app/api/appointments.py +++ b/app/api/appointments.py @@ -1,13 +1,14 @@ -import logging -import os +from datetime import datetime from io import BytesIO from typing import List, Optional +import httpx +import structlog from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response +from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse from sqlalchemy.orm import Session -from app.config import Config +from app.config import settings from app.crud import ( delete_background_image, delete_logo, @@ -21,6 +22,7 @@ save_logo, ) from app.database import DEFAULT_SETTING_NAME, get_db +from app.dependencies import get_http_client from app.schemas import ColorSettings, GenerateRequest from app.services.churchtools_client import AuthenticationError, fetch_appointments, fetch_calendars, parse_appointment from app.services.jpeg_generator import handle_jpeg_generation @@ -31,13 +33,13 @@ MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB -def _require_auth(request: Request): +def _require_auth(request: Request) -> None: """Raise 401 if no login token is present.""" - if not request.cookies.get(Config.COOKIE_LOGIN_TOKEN): + if not request.cookies.get(settings.cookie_login_token): raise HTTPException(status_code=401, detail="Nicht angemeldet") -logger = logging.getLogger(__name__) +logger = structlog.get_logger() router = APIRouter() @@ -60,11 +62,11 @@ def _build_template_context( "selected_calendar_ids": selected_calendar_ids, "start_date": start_date, "end_date": end_date, - "base_url": Config.CHURCHTOOLS_BASE, + "base_url": settings.churchtools_base, "color_settings": color_settings, "has_logo": has_logo, "has_background_image": has_background_image, - "version": Config.VERSION, + "version": settings.version, } context.update(extra) return context @@ -74,11 +76,12 @@ def _build_template_context( async def appointments_page( request: Request, db: Session = Depends(get_db), + client: httpx.AsyncClient = Depends(get_http_client), start_date: Optional[str] = Query(None), end_date: Optional[str] = Query(None), calendar_ids: Optional[List[str]] = Query(None), -): - login_token = request.cookies.get(Config.COOKIE_LOGIN_TOKEN) +) -> Response: + login_token = request.cookies.get(settings.cookie_login_token) if not login_token: return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) @@ -88,10 +91,10 @@ async def appointments_page( end_date = end_date or end_date_default try: - calendars = await fetch_calendars(login_token) + calendars = await fetch_calendars(login_token, client) except AuthenticationError: response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(key=Config.COOKIE_LOGIN_TOKEN) + response.delete_cookie(key=settings.cookie_login_token) return response # Use provided calendar_ids or preselect all @@ -125,12 +128,13 @@ async def appointments_page( async def api_appointments( request: Request, db: Session = Depends(get_db), + client: httpx.AsyncClient = Depends(get_http_client), start_date: str = Query(...), end_date: str = Query(...), calendar_ids: List[str] = Query(...), -): +) -> JSONResponse: """JSON endpoint for async appointment loading.""" - login_token = request.cookies.get(Config.COOKIE_LOGIN_TOKEN) + login_token = request.cookies.get(settings.cookie_login_token) if not login_token: return JSONResponse({"error": "not_authenticated"}, status_code=401) @@ -139,7 +143,7 @@ async def api_appointments( return JSONResponse({"appointments": []}) try: - raw_appointments = await fetch_appointments(login_token, start_date, end_date, calendar_ids_int) + raw_appointments = await fetch_appointments(login_token, start_date, end_date, calendar_ids_int, client) except AuthenticationError: return JSONResponse({"error": "not_authenticated"}, status_code=401) @@ -160,9 +164,10 @@ async def api_generate( request: Request, body: GenerateRequest, db: Session = Depends(get_db), -): + client: httpx.AsyncClient = Depends(get_http_client), +) -> Response: """JSON endpoint for PDF/JPEG generation.""" - login_token = request.cookies.get(Config.COOKIE_LOGIN_TOKEN) + login_token = request.cookies.get(settings.cookie_login_token) if not login_token: return JSONResponse({"error": "not_authenticated"}, status_code=401) @@ -177,19 +182,21 @@ async def api_generate( # Load background image and logo from DB background_image_stream = None - bg_data, _ = load_background_image(db, DEFAULT_SETTING_NAME) + bg_data, _ = load_background_image(db, body.profile) if bg_data: background_image_stream = BytesIO(bg_data) logo_stream = None - logo_data, _ = load_logo(db, DEFAULT_SETTING_NAME) + logo_data, _ = load_logo(db, body.profile) if logo_data: logo_stream = BytesIO(logo_data) # Fetch appointments from ChurchTools API calendar_ids_int = [int(cid) for cid in body.calendar_ids if cid.isdigit()] try: - raw_appointments = await fetch_appointments(login_token, body.start_date, body.end_date, calendar_ids_int) + raw_appointments = await fetch_appointments( + login_token, body.start_date, body.end_date, calendar_ids_int, client + ) except AuthenticationError: return JSONResponse({"error": "not_authenticated"}, status_code=401) @@ -210,7 +217,7 @@ async def api_generate( logger.info(f"Generating {body.type}: {len(selected_appointments)} of {len(appointments)} appointments") # Generate PDF - filename = create_pdf( + pdf_bytes = create_pdf( selected_appointments, color_settings.date_color, color_settings.background_color, @@ -220,15 +227,25 @@ async def api_generate( logo_stream, ) - # Convert to JPEG if requested - if body.type == "jpeg": - filename = handle_jpeg_generation(filename) + timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") - return JSONResponse({"download_url": f"/download/{filename}"}) + if body.type == "jpeg": + zip_bytes = handle_jpeg_generation(pdf_bytes) + return StreamingResponse( + BytesIO(zip_bytes), + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={timestamp}_appointments.zip"}, + ) + + return StreamingResponse( + BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={timestamp}_appointments.pdf"}, + ) @router.post("/logo/upload") -async def upload_logo(request: Request, file: UploadFile = File(...), db: Session = Depends(get_db)): +async def upload_logo(request: Request, file: UploadFile = File(...), db: Session = Depends(get_db)) -> JSONResponse: """Upload a logo image and store it in the database.""" _require_auth(request) content = await file.read(MAX_UPLOAD_SIZE + 1) @@ -241,7 +258,7 @@ async def upload_logo(request: Request, file: UploadFile = File(...), db: Sessio @router.get("/logo") -async def get_logo(db: Session = Depends(get_db)): +async def get_logo(db: Session = Depends(get_db)) -> Response: """Serve the stored logo image for preview.""" logo_data, logo_filename = load_logo(db, DEFAULT_SETTING_NAME) if not logo_data: @@ -255,7 +272,7 @@ async def get_logo(db: Session = Depends(get_db)): @router.delete("/logo") -async def remove_logo(request: Request, db: Session = Depends(get_db)): +async def remove_logo(request: Request, db: Session = Depends(get_db)) -> JSONResponse: """Delete the stored logo.""" _require_auth(request) delete_logo(db, DEFAULT_SETTING_NAME) @@ -263,7 +280,9 @@ async def remove_logo(request: Request, db: Session = Depends(get_db)): @router.post("/background/upload") -async def upload_background(request: Request, file: UploadFile = File(...), db: Session = Depends(get_db)): +async def upload_background( + request: Request, file: UploadFile = File(...), db: Session = Depends(get_db) +) -> JSONResponse: """Upload a background image and store it in the database.""" _require_auth(request) content = await file.read(MAX_UPLOAD_SIZE + 1) @@ -276,7 +295,7 @@ async def upload_background(request: Request, file: UploadFile = File(...), db: @router.get("/background") -async def get_background(db: Session = Depends(get_db)): +async def get_background(db: Session = Depends(get_db)) -> Response: """Serve the stored background image for preview.""" image_data, image_filename = load_background_image(db, DEFAULT_SETTING_NAME) if not image_data: @@ -290,22 +309,8 @@ async def get_background(db: Session = Depends(get_db)): @router.delete("/background") -async def remove_background(request: Request, db: Session = Depends(get_db)): +async def remove_background(request: Request, db: Session = Depends(get_db)) -> JSONResponse: """Delete the stored background image.""" _require_auth(request) delete_background_image(db, DEFAULT_SETTING_NAME) return JSONResponse({"status": "ok"}) - - -@router.get("/download/{filename}") -async def download_file(filename: str): - safe_filename = os.path.basename(filename) - file_path = os.path.join(Config.FILE_DIRECTORY, safe_filename) - if not os.path.exists(file_path): - raise HTTPException(status_code=404, detail="File not found") - - return FileResponse( - file_path, - filename=safe_filename, - headers={"Cache-Control": "no-store"}, - ) diff --git a/app/api/auth.py b/app/api/auth.py index 38255eb..e339ef4 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -1,97 +1,101 @@ import httpx -from fastapi import APIRouter, Form, Request, status -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, Depends, Form, Request, status +from fastapi.responses import RedirectResponse, Response -from app.config import Config +from app.config import settings +from app.dependencies import get_http_client from app.shared import templates router = APIRouter() @router.get("/") -async def login_page(request: Request): - login_token = request.cookies.get(Config.COOKIE_LOGIN_TOKEN) +async def login_page(request: Request) -> Response: + login_token = request.cookies.get(settings.cookie_login_token) if login_token: return RedirectResponse(url="/appointments", status_code=status.HTTP_303_SEE_OTHER) - context = {"request": request, "base_url": Config.CHURCHTOOLS_BASE, "version": Config.VERSION} + context = {"request": request, "base_url": settings.churchtools_base, "version": settings.version} return templates.TemplateResponse("login.html", context) @router.post("/") -async def login(request: Request, username: str = Form(...), password: str = Form(...)): +async def login( + request: Request, + username: str = Form(...), + password: str = Form(...), + client: httpx.AsyncClient = Depends(get_http_client), +) -> Response: data = {"password": password, "rememberMe": True, "username": username} - async with httpx.AsyncClient() as client: - response = await client.post(f"{Config.CHURCHTOOLS_BASE_URL}/api/login", json=data) - - if response.status_code == 200: - person_id = response.json()["data"]["personId"] - # Use session cookies from the login response to retrieve the long-lived login token. - # The OpenAPI spec documents Authorization header auth for this endpoint, - # but right after login we only have session cookies (no login token yet). - token_response = await client.get( - f"{Config.CHURCHTOOLS_BASE_URL}/api/persons/{person_id}/logintoken", cookies=response.cookies + response = await client.post(f"{settings.churchtools_base_url}/api/login", json=data) + + if response.status_code == 200: + person_id = response.json()["data"]["personId"] + # Use session cookies from the login response to retrieve the long-lived login token. + # The OpenAPI spec documents Authorization header auth for this endpoint, + # but right after login we only have session cookies (no login token yet). + token_response = await client.get( + f"{settings.churchtools_base_url}/api/persons/{person_id}/logintoken", cookies=response.cookies + ) + + if token_response.status_code == 200: + login_token = token_response.json()["data"] + redirect = RedirectResponse(url="/appointments", status_code=status.HTTP_303_SEE_OTHER) + is_https = request.url.scheme == "https" + redirect.set_cookie( + key=settings.cookie_login_token, + value=login_token, + httponly=True, + secure=is_https, + samesite="strict" if is_https else "lax", ) - - if token_response.status_code == 200: - login_token = token_response.json()["data"] - redirect = RedirectResponse(url="/appointments", status_code=status.HTTP_303_SEE_OTHER) - is_https = request.url.scheme == "https" - redirect.set_cookie( - key=Config.COOKIE_LOGIN_TOKEN, - value=login_token, - httponly=True, - secure=is_https, - samesite="strict" if is_https else "lax", - ) - return redirect - else: - return templates.TemplateResponse( - "login.html", - { - "request": request, - "base_url": Config.CHURCHTOOLS_BASE, - "error": "Login-Token konnte nicht abgerufen werden.", - "version": Config.VERSION, - }, - ) + return redirect else: return templates.TemplateResponse( "login.html", { "request": request, - "base_url": Config.CHURCHTOOLS_BASE, - "error": "Benutzername oder Passwort ungültig.", - "version": Config.VERSION, + "base_url": settings.churchtools_base, + "error": "Login-Token konnte nicht abgerufen werden.", + "version": settings.version, }, ) + else: + return templates.TemplateResponse( + "login.html", + { + "request": request, + "base_url": settings.churchtools_base, + "error": "Benutzername oder Passwort ungültig.", + "version": settings.version, + }, + ) @router.post("/logout") -async def logout(request: Request): - login_token = request.cookies.get(Config.COOKIE_LOGIN_TOKEN) +async def logout(request: Request, client: httpx.AsyncClient = Depends(get_http_client)) -> RedirectResponse: + login_token = request.cookies.get(settings.cookie_login_token) if login_token: try: - async with httpx.AsyncClient() as client: - await client.post( - f"{Config.CHURCHTOOLS_BASE_URL}/api/logout", - headers={"Authorization": f"Login {login_token}"}, - ) + await client.post( + f"{settings.churchtools_base_url}/api/logout", + headers={"Authorization": f"Login {login_token}"}, + ) except Exception: pass # Best-effort: still clear local cookie even if API call fails response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) - response.delete_cookie(key=Config.COOKIE_LOGIN_TOKEN) + response.delete_cookie(key=settings.cookie_login_token) return response @router.get("/overview") -async def overview(request: Request): - login_token = request.cookies.get(Config.COOKIE_LOGIN_TOKEN) +async def overview(request: Request) -> Response: + login_token = request.cookies.get(settings.cookie_login_token) if not login_token: return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER) return templates.TemplateResponse( - "overview.html", {"request": request, "base_url": Config.CHURCHTOOLS_BASE, "version": Config.VERSION} + "overview.html", {"request": request, "base_url": settings.churchtools_base, "version": settings.version} ) diff --git a/app/api/fragments.py b/app/api/fragments.py new file mode 100644 index 0000000..bebed14 --- /dev/null +++ b/app/api/fragments.py @@ -0,0 +1,47 @@ +import structlog +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session + +from app.config import settings +from app.crud import get_additional_infos +from app.database import get_db +from app.dependencies import get_http_client +from app.services.churchtools_client import AuthenticationError, fetch_appointments, parse_appointment +from app.shared import templates + +logger = structlog.get_logger() +router = APIRouter(prefix="/fragments") + + +@router.get("/appointments") +async def fragment_appointments( + request: Request, + db: Session = Depends(get_db), + start_date: str = Query(...), + end_date: str = Query(...), + calendar_ids: list[str] = Query(default=[]), + client=Depends(get_http_client), +): + login_token = request.cookies.get(settings.cookie_login_token) + if not login_token: + return HTMLResponse("

Nicht angemeldet

", status_code=401) + + calendar_ids_int = [int(cid) for cid in calendar_ids if cid.isdigit()] + if not calendar_ids_int: + return HTMLResponse("

Keine Kalender ausgewählt

") + + try: + raw_appointments = await fetch_appointments(login_token, start_date, end_date, calendar_ids_int, client) + except AuthenticationError: + return HTMLResponse("

Sitzung abgelaufen

", status_code=401) + + appointments = [parse_appointment(raw) for raw in raw_appointments] + additional_infos = get_additional_infos(db, [app.id for app in appointments]) + for appointment in appointments: + appointment.additional_info = additional_infos.get(appointment.id, "") + + return templates.TemplateResponse( + "fragments/appointments.html", + {"request": request, "appointments": appointments}, + ) diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..5a64cea --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +from app.config import settings + +router = APIRouter() + + +@router.get("/health") +async def health() -> JSONResponse: + return JSONResponse({"status": "ok", "version": settings.version}) diff --git a/app/config.py b/app/config.py index b8075f8..7246b26 100644 --- a/app/config.py +++ b/app/config.py @@ -1,33 +1,47 @@ -import logging -import os import tomllib +from pathlib import Path +from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -logger = logging.getLogger(__name__) +from pydantic import Field, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict -def get_version(): - """Read version from pyproject.toml (single source of truth).""" +def _read_version() -> str: try: - base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - with open(os.path.join(base_dir, "pyproject.toml"), "rb") as f: + pyproject = Path(__file__).parent.parent / "pyproject.toml" + with open(pyproject, "rb") as f: return tomllib.load(f)["project"]["version"] - except Exception as e: - logger.error(f"Error reading version: {e}") - return "0.0.0" - - -class Config: - COOKIE_LOGIN_TOKEN = "login_token" - VERSION = get_version() - CHURCHTOOLS_BASE = os.getenv("CHURCHTOOLS_BASE", "") - DB_PATH = os.getenv("DB_PATH", "churchtools.db") - CHURCHTOOLS_BASE_URL = os.getenv("CHURCHTOOLS_BASE_URL", f"https://{CHURCHTOOLS_BASE}") - FILE_DIRECTORY = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "saved_files") - - @classmethod - def validate(cls): - missing = [] - if cls.CHURCHTOOLS_BASE.startswith(" "Settings": + if not self.churchtools_base_url and self.churchtools_base: + self.churchtools_base_url = f"https://{self.churchtools_base}" + try: + object.__setattr__(self, "timezone", ZoneInfo(self.timezone_name)) + except (ZoneInfoNotFoundError, KeyError) as e: + raise ValueError(f"Invalid timezone: {self.timezone_name}") from e + return self + + +settings = Settings() diff --git a/app/crud.py b/app/crud.py index 11f3f0b..3c59086 100644 --- a/app/crud.py +++ b/app/crud.py @@ -1,14 +1,14 @@ -import logging - +import structlog from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session from app.models import Appointment, BackgroundImageSetting, ColorSetting, LogoSetting from app.schemas import ColorSettings -logger = logging.getLogger(__name__) +logger = structlog.get_logger() -def save_additional_infos(db, appointment_info_list): +def save_additional_infos(db: Session, appointment_info_list: list[tuple[str, str]]) -> None: try: for appointment_id, additional_info in appointment_info_list: appointment = db.query(Appointment).filter(Appointment.id == appointment_id).first() @@ -22,7 +22,7 @@ def save_additional_infos(db, appointment_info_list): raise -def get_additional_infos(db, appointment_ids): +def get_additional_infos(db: Session, appointment_ids: list[str]) -> dict[str, str]: try: results = db.query(Appointment).filter(Appointment.id.in_(appointment_ids)).all() return {appointment.id: appointment.additional_info for appointment in results} @@ -31,7 +31,7 @@ def get_additional_infos(db, appointment_ids): return {} -def save_color_settings(db, settings: ColorSettings): +def save_color_settings(db: Session, settings: ColorSettings) -> None: try: color_setting = db.query(ColorSetting).filter(ColorSetting.setting_name == settings.name).first() if color_setting: @@ -55,7 +55,7 @@ def save_color_settings(db, settings: ColorSettings): raise -def load_color_settings(db, setting_name) -> ColorSettings: +def load_color_settings(db: Session, setting_name: str) -> ColorSettings: try: color_setting = db.query(ColorSetting).filter(ColorSetting.setting_name == setting_name).first() if color_setting: @@ -73,7 +73,7 @@ def load_color_settings(db, setting_name) -> ColorSettings: return ColorSettings(name=setting_name) -def save_logo(db, setting_name: str, logo_data: bytes, filename: str): +def save_logo(db: Session, setting_name: str, logo_data: bytes, filename: str) -> None: try: logo = db.query(LogoSetting).filter(LogoSetting.setting_name == setting_name).first() if logo: @@ -87,7 +87,7 @@ def save_logo(db, setting_name: str, logo_data: bytes, filename: str): raise -def load_logo(db, setting_name: str): +def load_logo(db: Session, setting_name: str) -> tuple[bytes | None, str | None]: try: logo = db.query(LogoSetting).filter(LogoSetting.setting_name == setting_name).first() if logo: @@ -98,7 +98,7 @@ def load_logo(db, setting_name: str): return None, None -def delete_logo(db, setting_name: str): +def delete_logo(db: Session, setting_name: str) -> None: try: logo = db.query(LogoSetting).filter(LogoSetting.setting_name == setting_name).first() if logo: @@ -109,7 +109,7 @@ def delete_logo(db, setting_name: str): raise -def save_background_image(db, setting_name: str, image_data: bytes, filename: str): +def save_background_image(db: Session, setting_name: str, image_data: bytes, filename: str) -> None: try: bg = db.query(BackgroundImageSetting).filter(BackgroundImageSetting.setting_name == setting_name).first() if bg: @@ -123,7 +123,7 @@ def save_background_image(db, setting_name: str, image_data: bytes, filename: st raise -def load_background_image(db, setting_name: str): +def load_background_image(db: Session, setting_name: str) -> tuple[bytes | None, str | None]: try: bg = db.query(BackgroundImageSetting).filter(BackgroundImageSetting.setting_name == setting_name).first() if bg: @@ -134,7 +134,7 @@ def load_background_image(db, setting_name: str): return None, None -def delete_background_image(db, setting_name: str): +def delete_background_image(db: Session, setting_name: str) -> None: try: bg = db.query(BackgroundImageSetting).filter(BackgroundImageSetting.setting_name == setting_name).first() if bg: @@ -143,3 +143,65 @@ def delete_background_image(db, setting_name: str): except SQLAlchemyError: db.rollback() raise + + +def list_profiles(db: Session) -> list[str]: + results = db.query(ColorSetting.setting_name).distinct().all() + return [r[0] for r in results] + + +def clone_profile(db: Session, source: str, target: str) -> None: + source_colors = db.query(ColorSetting).filter(ColorSetting.setting_name == source).first() + if not source_colors: + raise ValueError(f"Source profile '{source}' does not exist") + + db.add( + ColorSetting( + setting_name=target, + background_color=source_colors.background_color, + background_alpha=source_colors.background_alpha, + date_color=source_colors.date_color, + description_color=source_colors.description_color, + ) + ) + + source_logo = db.query(LogoSetting).filter(LogoSetting.setting_name == source).first() + if source_logo: + db.add( + LogoSetting( + setting_name=target, + logo_data=source_logo.logo_data, + logo_filename=source_logo.logo_filename, + ) + ) + + source_bg = db.query(BackgroundImageSetting).filter(BackgroundImageSetting.setting_name == source).first() + if source_bg: + db.add( + BackgroundImageSetting( + setting_name=target, + image_data=source_bg.image_data, + image_filename=source_bg.image_filename, + ) + ) + + db.commit() + + +def delete_profile(db: Session, profile_name: str) -> None: + if profile_name == "default": + raise ValueError("Cannot delete the default profile") + + db.query(BackgroundImageSetting).filter(BackgroundImageSetting.setting_name == profile_name).delete() + db.query(LogoSetting).filter(LogoSetting.setting_name == profile_name).delete() + db.query(ColorSetting).filter(ColorSetting.setting_name == profile_name).delete() + db.commit() + + +def cleanup_orphaned_settings(db: Session) -> None: + valid_profiles = {r[0] for r in db.query(ColorSetting.setting_name).all()} + db.query(LogoSetting).filter(~LogoSetting.setting_name.in_(valid_profiles)).delete(synchronize_session=False) + db.query(BackgroundImageSetting).filter(~BackgroundImageSetting.setting_name.in_(valid_profiles)).delete( + synchronize_session=False + ) + db.commit() diff --git a/app/database.py b/app/database.py index 51c63b6..3cc65f0 100644 --- a/app/database.py +++ b/app/database.py @@ -1,20 +1,14 @@ from sqlalchemy import create_engine from sqlalchemy.orm import declarative_base, sessionmaker -from app.config import Config +from app.config import settings DEFAULT_SETTING_NAME = "default" -# SQLite database URL -SQLALCHEMY_DATABASE_URL = f"sqlite:///{Config.DB_PATH}" +SQLALCHEMY_DATABASE_URL = f"sqlite:///{settings.db_path}" -# Create engine engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) - -# Create session factory SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -# Base class for models Base = declarative_base() @@ -24,10 +18,3 @@ def get_db(): yield db finally: db.close() - - -def create_schema(): - # Import models so they register with Base.metadata before creating tables - import app.models # noqa: F401 - - Base.metadata.create_all(bind=engine) diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..ee9e79f --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,6 @@ +import httpx +from fastapi import Request + + +def get_http_client(request: Request) -> httpx.AsyncClient: + return request.app.state.http_client diff --git a/app/logging_config.py b/app/logging_config.py new file mode 100644 index 0000000..0896617 --- /dev/null +++ b/app/logging_config.py @@ -0,0 +1,45 @@ +import logging +import sys + +import structlog + + +def configure_logging(log_format: str = "console") -> None: + shared_processors = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + ] + + if log_format == "json": + shared_processors.append(structlog.processors.format_exc_info) + renderer = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer() + + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(logging.INFO) diff --git a/app/main.py b/app/main.py index ef90ffe..7eb046d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,18 +1,19 @@ +from contextlib import asynccontextmanager from pathlib import Path -from dotenv import load_dotenv +import httpx from fastapi import FastAPI, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware -# Load environment variables from .env file -load_dotenv() +from app.api import appointments, auth, fragments, health +from app.config import settings +from app.logging_config import configure_logging +from app.middleware.csrf import CSRFMiddleware -from app.api import appointments, auth -from app.config import Config -from app.database import create_schema - -Config.validate() +configure_logging(settings.log_format) class SecurityHeadersMiddleware(BaseHTTPMiddleware): @@ -25,21 +26,51 @@ async def dispatch(self, request: Request, call_next) -> Response: return response +@asynccontextmanager +async def lifespan(app: FastAPI): + from app.crud import cleanup_orphaned_settings + from app.database import SessionLocal + + db = SessionLocal() + try: + cleanup_orphaned_settings(db) + finally: + db.close() + + app.state.http_client = httpx.AsyncClient(timeout=30.0) + yield + await app.state.http_client.aclose() + + # Create FastAPI application -app = FastAPI(title="ChurchTools API") +app = FastAPI(title="ChurchTools API", lifespan=lifespan) app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware(CSRFMiddleware, exempt_paths=["/health"]) # Include static files app.mount("/static", StaticFiles(directory="app/static"), name="static") -# Make sure the directories for saved files and DB exist -Path(Config.FILE_DIRECTORY).mkdir(parents=True, exist_ok=True) -Path(Config.DB_PATH).parent.mkdir(parents=True, exist_ok=True) +# Make sure the directory for DB exists +Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True) + + +@app.exception_handler(401) +async def unauthorized_handler(request: Request, exc): + return JSONResponse({"error": "unauthorized", "detail": str(exc.detail)}, status_code=401) + + +@app.exception_handler(404) +async def not_found_handler(request: Request, exc): + return JSONResponse({"error": "not_found", "detail": str(exc.detail)}, status_code=404) + + +@app.exception_handler(RequestValidationError) +async def validation_handler(request: Request, exc): + return JSONResponse({"error": "validation_error", "detail": str(exc)}, status_code=422) + # Include routes +app.include_router(health.router, tags=["health"]) app.include_router(auth.router, tags=["auth"]) app.include_router(appointments.router, tags=["appointments"]) - - -# Create database schema -create_schema() +app.include_router(fragments.router, tags=["fragments"]) diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py new file mode 100644 index 0000000..42b6f2e --- /dev/null +++ b/app/middleware/csrf.py @@ -0,0 +1,59 @@ +import secrets + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +COOKIE_NAME = "csrf_token" + + +class CSRFMiddleware(BaseHTTPMiddleware): + def __init__(self, app, exempt_paths: list[str] | None = None): + super().__init__(app) + self.exempt_paths = set(exempt_paths or []) + + async def dispatch(self, request: Request, call_next) -> Response: + if request.method in ("GET", "HEAD", "OPTIONS"): + # Reuse existing token so templates can read it from the incoming cookie. + # Only generate a new one if no cookie exists yet. + if not request.cookies.get(COOKIE_NAME): + token = secrets.token_urlsafe(32) + # Inject into request scope so templates see it via request.cookies + request._cookies[COOKIE_NAME] = token + else: + token = request.cookies[COOKIE_NAME] + + response = await call_next(request) + response.set_cookie( + COOKIE_NAME, + token, + httponly=False, + samesite="lax", + secure=request.url.scheme == "https", + ) + return response + + if request.url.path in self.exempt_paths: + return await call_next(request) + + cookie_token = request.cookies.get(COOKIE_NAME) + if not cookie_token: + return JSONResponse({"error": "CSRF token missing"}, status_code=403) + + header_token = request.headers.get("X-CSRF-Token") + if header_token and secrets.compare_digest(header_token, cookie_token): + return await call_next(request) + + content_type = request.headers.get("content-type", "") + if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type: + # Cache the body so downstream handlers (FastAPI Form()) can read it again + body = await request.body() + form = await request.form() + form_token = form.get("_csrf_token") + await form.close() + # Re-inject cached body so FastAPI can parse it again + request._body = body + if form_token and secrets.compare_digest(str(form_token), cookie_token): + return await call_next(request) + + return JSONResponse({"error": "CSRF token mismatch"}, status_code=403) diff --git a/app/schemas.py b/app/schemas.py index 3cf7510..db73992 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -8,6 +8,11 @@ _HEX_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{6}$") +class ErrorResponse(BaseModel): + error: str + detail: str | None = None + + class AppointmentData(BaseModel): """Structured representation of a ChurchTools appointment for display and export.""" @@ -67,6 +72,7 @@ class GenerateRequest(BaseModel): appointment_ids: List[str] color_settings: ColorSettings additional_infos: Dict[str, str] = {} + profile: str = "default" @field_validator("appointment_ids") @classmethod diff --git a/app/services/churchtools_client.py b/app/services/churchtools_client.py index cc77ed5..3fcde15 100644 --- a/app/services/churchtools_client.py +++ b/app/services/churchtools_client.py @@ -1,14 +1,14 @@ import asyncio -import logging from typing import List import httpx +import structlog -from app.config import Config +from app.config import settings from app.schemas import AppointmentData from app.utils import parse_iso_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger() class AuthenticationError(Exception): @@ -30,30 +30,29 @@ def _extract_appointment(item: dict) -> dict: return item -async def fetch_calendars(login_token: str): - url = f"{Config.CHURCHTOOLS_BASE_URL}/api/calendars" +async def fetch_calendars(login_token: str, client: httpx.AsyncClient): + url = f"{settings.churchtools_base_url}/api/calendars" - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=_auth_headers(login_token)) + response = await client.get(url, headers=_auth_headers(login_token)) - if response.status_code in (401, 403): - raise AuthenticationError("Login token is invalid or expired") + if response.status_code in (401, 403): + raise AuthenticationError("Login token is invalid or expired") - if response.status_code == 200: - all_calendars = response.json().get("data", []) - # isPublic is deprecated in the API but no replacement is documented yet. - # We keep using it until ChurchTools provides a documented alternative. - public_calendars = [calendar for calendar in all_calendars if calendar.get("isPublic") is True] - return public_calendars - else: - response.raise_for_status() + if response.status_code == 200: + all_calendars = response.json().get("data", []) + # isPublic is deprecated in the API but no replacement is documented yet. + # We keep using it until ChurchTools provides a documented alternative. + public_calendars = [calendar for calendar in all_calendars if calendar.get("isPublic") is True] + return public_calendars + else: + response.raise_for_status() async def _fetch_calendar_appointments( client: httpx.AsyncClient, calendar_id: int, headers: dict, query_params: dict ) -> list[tuple[int, dict]]: """Fetch appointments for a single calendar. Returns list of (calendar_id, appointment_dict) tuples.""" - url = f"{Config.CHURCHTOOLS_BASE_URL}/api/calendars/{calendar_id}/appointments" + url = f"{settings.churchtools_base_url}/api/calendars/{calendar_id}/appointments" response = await client.get(url, headers=headers, params=query_params) if response.status_code in (401, 403): @@ -66,7 +65,9 @@ async def _fetch_calendar_appointments( return [(calendar_id, _extract_appointment(item)) for item in response.json()["data"]] -async def fetch_appointments(login_token: str, start_date: str, end_date: str, calendar_ids: List[int]): +async def fetch_appointments( + login_token: str, start_date: str, end_date: str, calendar_ids: List[int], client: httpx.AsyncClient +): headers = _auth_headers(login_token) query_params = { "from": start_date, @@ -75,28 +76,27 @@ async def fetch_appointments(login_token: str, start_date: str, end_date: str, c appointments = [] seen_ids = set() - async with httpx.AsyncClient() as client: - # Fetch all calendars in parallel - tasks = [_fetch_calendar_appointments(client, cal_id, headers, query_params) for cal_id in calendar_ids] - results = await asyncio.gather(*tasks) + # Fetch all calendars in parallel + tasks = [_fetch_calendar_appointments(client, cal_id, headers, query_params) for cal_id in calendar_ids] + results = await asyncio.gather(*tasks) - for calendar_results in results: - appointment_counts = {} + for calendar_results in results: + appointment_counts = {} - for calendar_id, appointment in calendar_results: - base_id = str(appointment["base"]["id"]) - appointment_id = str(calendar_id) + "_" + base_id + for calendar_id, appointment in calendar_results: + base_id = str(appointment["base"]["id"]) + appointment_id = str(calendar_id) + "_" + base_id - if appointment_id in appointment_counts: - appointment_counts[appointment_id] += 1 - appointment_id += f"_{appointment_counts[appointment_id]}" - else: - appointment_counts[appointment_id] = 0 + if appointment_id in appointment_counts: + appointment_counts[appointment_id] += 1 + appointment_id += f"_{appointment_counts[appointment_id]}" + else: + appointment_counts[appointment_id] = 0 - if appointment_id not in seen_ids: - seen_ids.add(appointment_id) - appointment["base"]["id"] = appointment_id - appointments.append(appointment) + if appointment_id not in seen_ids: + seen_ids.add(appointment_id) + appointment["base"]["id"] = appointment_id + appointments.append(appointment) appointments.sort(key=lambda x: parse_iso_datetime(x["calculated"]["startDate"])) return appointments diff --git a/app/services/jpeg_generator.py b/app/services/jpeg_generator.py index 2d98d73..11f465c 100644 --- a/app/services/jpeg_generator.py +++ b/app/services/jpeg_generator.py @@ -1,32 +1,21 @@ -import logging -import os import zipfile from io import BytesIO -from pdf2image import convert_from_path +import structlog +from pdf2image import convert_from_bytes -from app.config import Config +logger = structlog.get_logger() -logger = logging.getLogger(__name__) +def handle_jpeg_generation(pdf_bytes: bytes) -> bytes: + images = convert_from_bytes(pdf_bytes) + zip_buffer = BytesIO() -def handle_jpeg_generation(pdf_filename): - full_pdf_path = os.path.join(Config.FILE_DIRECTORY, pdf_filename) - images = convert_from_path(full_pdf_path) - jpeg_files = [] + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for i, image in enumerate(images): + jpeg_stream = BytesIO() + image.save(jpeg_stream, "JPEG") + zip_file.writestr(f"page_{i + 1}.jpg", jpeg_stream.getvalue()) - for i, image in enumerate(images): - jpeg_stream = BytesIO() - image.save(jpeg_stream, "JPEG") - jpeg_stream.seek(0) - jpeg_files.append((f"page_{i + 1}.jpg", jpeg_stream)) - - zip_filename = os.path.splitext(pdf_filename)[0] + ".zip" - zip_path = os.path.join(Config.FILE_DIRECTORY, zip_filename) - - with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_file: - for file_name, file_bytes in jpeg_files: - zip_file.writestr(file_name, file_bytes.read()) - - logger.info(f"JPEG images successfully created and packed into ZIP file: {zip_filename}") - return zip_filename + logger.info(f"JPEG images generated: {len(images)} pages") + return zip_buffer.getvalue() diff --git a/app/services/pdf_generator.py b/app/services/pdf_generator.py index 0e83a2f..95bb76b 100644 --- a/app/services/pdf_generator.py +++ b/app/services/pdf_generator.py @@ -1,8 +1,7 @@ import io -import logging -import os -from datetime import datetime +from pathlib import Path +import structlog from babel.dates import format_date from PIL import Image, ImageColor from reportlab.lib.colors import HexColor, black @@ -12,11 +11,10 @@ from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen import canvas -from app.config import Config from app.schemas import AppointmentData from app.utils import normalize_newlines, parse_iso_datetime -logger = logging.getLogger(__name__) +logger = structlog.get_logger() # Layout constants (16:9 page for church projector display) PAGE_WIDTH = 1200 @@ -43,7 +41,7 @@ FALLBACK_FONT_BOLD = "Helvetica-Bold" # Resolve fonts/ directory relative to project root (two levels up from this file) -_FONTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "fonts") +_FONTS_DIR = Path(__file__).resolve().parent.parent.parent / "fonts" _cached_fonts = None @@ -61,7 +59,7 @@ def _register_fonts(): try: if font_name not in pdfmetrics.getRegisteredFontNames(): try: - pdfmetrics.registerFont(TTFont(font_name, os.path.join(_FONTS_DIR, f"{font_name}.ttf"))) + pdfmetrics.registerFont(TTFont(font_name, str(_FONTS_DIR / f"{font_name}.ttf"))) except Exception as e: logger.error(f"Error registering font {font_name}: {e}") font_name = FALLBACK_FONT @@ -71,17 +69,15 @@ def _register_fonts(): try: if font_name == PREFERRED_FONT: # Bahnschrift uses the same file for bold - pdfmetrics.registerFont(TTFont(bold_font_name, os.path.join(_FONTS_DIR, f"{font_name}.ttf"))) + pdfmetrics.registerFont(TTFont(bold_font_name, str(_FONTS_DIR / f"{font_name}.ttf"))) else: - pdfmetrics.registerFont(TTFont(bold_font_name, os.path.join(_FONTS_DIR, f"{font_name}-Bold.ttf"))) + pdfmetrics.registerFont(TTFont(bold_font_name, str(_FONTS_DIR / f"{font_name}-Bold.ttf"))) except Exception as e: logger.error(f"Error registering bold font {bold_font_name}: {e}") bold_font_name = FALLBACK_FONT_BOLD if FALLBACK_FONT_BOLD not in pdfmetrics.getRegisteredFontNames(): try: - pdfmetrics.registerFont( - TTFont(FALLBACK_FONT_BOLD, os.path.join(_FONTS_DIR, "helvetica-bold.ttf")) - ) + pdfmetrics.registerFont(TTFont(FALLBACK_FONT_BOLD, str(_FONTS_DIR / "helvetica-bold.ttf"))) except Exception as e2: logger.error(f"Error registering font {FALLBACK_FONT_BOLD}: {e2}") bold_font_name = FALLBACK_FONT @@ -349,14 +345,12 @@ def _draw_event( def create_pdf( appointments, date_color, background_color, description_color, alpha, image_stream=None, logo_stream=None -): +) -> bytes: font_name, font_name_bold = _register_fonts() - current_day = datetime.now().strftime("%Y-%m-%d_%H%M%S") - filename = f"{current_day}_Termine.pdf" - file_path = os.path.join(Config.FILE_DIRECTORY, filename) - c = canvas.Canvas(file_path, pagesize=landscape(PAGE_SIZE)) - c.setTitle(filename) + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=landscape(PAGE_SIZE)) + c.setTitle("appointments") try: if image_stream: @@ -385,5 +379,5 @@ def create_pdf( ) c.save() - logger.info(f"PDF successfully created: {filename} with {len(appointments)} appointments") - return filename + logger.info(f"PDF successfully created with {len(appointments)} appointments") + return buffer.getvalue() diff --git a/app/static/css/appointments.css b/app/static/css/appointments.css index 6398d34..d1bbd10 100644 --- a/app/static/css/appointments.css +++ b/app/static/css/appointments.css @@ -1,5 +1,10 @@ /* appointments.css — Soft sage redesign */ +/* ─── Layout tokens (page-specific) ─────────── */ +.container { + --checkbox-indent: calc(18px + var(--spacing-sm)); +} + /* ─── Container ─────────────────────────────── */ .container { max-width: 720px; @@ -22,7 +27,7 @@ font-family: var(--font-display); font-size: 2.4rem; font-weight: 600; - margin-bottom: 4px; + margin-bottom: var(--spacing-xs); letter-spacing: -0.03em; color: var(--text-color); font-optical-sizing: auto; @@ -30,7 +35,7 @@ .page-header .subtitle { color: var(--text-muted); - font-size: 0.85rem; + font-size: 0.9375rem; margin: 0; font-weight: 400; } @@ -38,10 +43,10 @@ .back-link { display: inline-flex; align-items: center; - gap: 4px; + gap: var(--spacing-xs); color: var(--text-muted); text-decoration: none; - font-size: 0.82rem; + font-size: 0.875rem; font-weight: 500; transition: color var(--transition-speed) var(--transition-ease); margin-bottom: var(--spacing-sm); @@ -87,7 +92,7 @@ .date-separator { color: var(--text-muted); font-size: 1.1rem; - padding-bottom: 8px; + padding-bottom: var(--spacing-sm); flex-shrink: 0; } @@ -98,10 +103,10 @@ .date-field label { display: block; - margin-bottom: 4px; + margin-bottom: var(--spacing-xs); margin-top: 0; font-weight: 600; - font-size: 0.72rem; + font-size: 0.8125rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; @@ -109,10 +114,10 @@ .date-field input[type="text"] { width: 100%; - padding: 10px 14px; + padding: 0.625em 0.875em; border: 1px solid var(--border-color); border-radius: var(--border-radius); - font-size: 0.95rem; + font-size: 1rem; font-family: var(--font-family); background: white; transition: border-color var(--transition-speed) var(--transition-ease), @@ -129,16 +134,16 @@ .date-presets { display: flex; flex-wrap: wrap; - gap: 6px; + gap: var(--spacing-2xs); } .preset-chip { background-color: var(--primary-subtle); border: 1px solid var(--border-color); border-radius: 100px; - padding: 6px 16px; + padding: 0.5em 1.125em; font-family: var(--font-family); - font-size: 0.8rem; + font-size: 0.875rem; font-weight: 500; cursor: pointer; transition: all var(--transition-speed) var(--transition-ease); @@ -165,16 +170,16 @@ .chips-toggle { display: flex; align-items: center; - gap: 8px; + gap: var(--spacing-sm); width: 100%; - padding: 10px 14px; + padding: 0.625em 0.875em; margin: 0; background: var(--primary-subtle); border: 1px solid var(--border-color); border-radius: var(--border-radius); cursor: pointer; font-family: var(--font-family); - font-size: 0.85rem; + font-size: 0.9375rem; font-weight: 600; color: var(--text-color); transition: all var(--transition-speed) var(--transition-ease); @@ -194,7 +199,7 @@ .calendar-selection-info { color: var(--text-muted); - font-size: 0.78rem; + font-size: 0.875rem; font-weight: 500; margin-left: auto; } @@ -217,12 +222,24 @@ .chips-wrap { display: flex; flex-wrap: wrap; - gap: 6px; - padding: 12px 14px; - border: 1px solid var(--border-color); + justify-content: center; + gap: var(--spacing-2xs); + padding: 0 0.875em; + border: 0 solid var(--border-color); border-top: none; border-radius: 0 0 var(--border-radius) var(--border-radius); background: white; + max-height: 0; + overflow: hidden; + opacity: 0; + transition: max-height 0.25s ease, opacity 0.25s ease, padding 0.25s ease, border-width 0.05s ease; +} + +.chips-wrap.is-open { + max-height: 500px; + opacity: 1; + padding: 0.75em 0.875em; + border-width: 1px; } .calendar-chip { @@ -243,9 +260,9 @@ .chip-text { display: inline-block; - padding: 7px 14px; + padding: 0.5em 1em; border-radius: 100px; - font-size: 0.8rem; + font-size: 0.875rem; font-weight: 500; font-family: var(--font-family); border: 1px solid var(--border-color); @@ -253,8 +270,6 @@ color: var(--text-color); transition: all var(--transition-speed) var(--transition-ease); user-select: none; - min-height: 36px; - line-height: 1.3; } .calendar-chip input:checked + .chip-text { @@ -274,8 +289,8 @@ margin-top: 0; display: block; width: 100%; - padding: 14px 20px; - font-size: 0.95rem; + padding: 0.875em 1.25em; + font-size: 1rem; font-weight: 600; letter-spacing: 0.02em; border: none; @@ -285,7 +300,7 @@ .btn-fetch:hover { background-color: var(--primary-hover); - box-shadow: 0 4px 18px rgba(94, 139, 90, 0.25); + box-shadow: var(--box-shadow-hover); transform: translateY(-1px); } @@ -305,7 +320,7 @@ } .appointment-count { - font-size: 0.85rem; + font-size: 0.9375rem; color: var(--text-muted); margin-right: auto; font-weight: 500; @@ -314,9 +329,9 @@ .select-all-btn, .deselect-all-btn { font-family: var(--font-family); - font-size: 0.78rem; + font-size: 0.875rem; font-weight: 600; - padding: 6px 14px; + padding: 0.375em 0.875em; border-radius: 100px; cursor: pointer; transition: all var(--transition-speed) var(--transition-ease); @@ -375,14 +390,14 @@ top: 0; z-index: 2; font-family: var(--font-display); - font-size: 0.85rem; + font-size: 0.9375rem; font-weight: 600; color: var(--text-muted); background: linear-gradient(to bottom, white 60%, rgba(255,255,255,0.85)); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); - padding: 10px 16px 6px; - margin-top: 4px; + padding: var(--spacing-sm) var(--spacing-md) var(--spacing-2xs); + margin-top: var(--spacing-xs); border-bottom: 1px solid var(--border-color); letter-spacing: 0.01em; } @@ -405,7 +420,7 @@ .appointment-item { display: flex; align-items: center; - padding: 8px 12px; + padding: var(--spacing-sm) var(--spacing-md); margin-bottom: 0; flex-wrap: wrap; background: transparent; @@ -447,7 +462,7 @@ padding: 0; display: flex; align-items: baseline; - gap: 6px; + gap: var(--spacing-2xs); flex-wrap: wrap; margin: 0; font-weight: normal; @@ -459,13 +474,13 @@ .appointment-date { font-weight: 600; color: var(--secondary-color); - font-size: 0.82rem; + font-size: 0.9375rem; font-family: var(--font-family); } .appointment-description { color: var(--text-color); - font-size: 0.9rem; + font-size: 1rem; margin-top: 0; } @@ -477,10 +492,10 @@ overflow: hidden; white-space: pre-line; width: 100%; - margin-left: 28px; - font-size: 0.78rem; + margin-left: var(--checkbox-indent); + font-size: 0.875rem; color: var(--text-muted); - line-height: 1.4; + line-height: 1.45; padding: 2px 0; cursor: pointer; transition: color var(--transition-speed) var(--transition-ease); @@ -507,10 +522,10 @@ border-radius: 100px; color: var(--primary-color); font-family: var(--font-family); - font-size: 0.72rem; + font-size: 0.8125rem; font-weight: 600; cursor: pointer; - padding: 3px 10px; + padding: 0.3em 0.75em; margin: 0; margin-left: auto; transition: all var(--transition-speed) var(--transition-ease); @@ -531,8 +546,8 @@ textarea.hidden { /* Textarea */ textarea { width: 100%; - padding: 8px 12px; - margin-top: 4px; + padding: var(--spacing-sm) 0.75em; + margin-top: var(--spacing-xs); border: 1px solid var(--border-color); border-radius: var(--border-radius); resize: none; @@ -540,8 +555,8 @@ textarea { height: 24px; overflow-y: hidden; font-family: var(--font-family); - font-size: 0.85rem; - line-height: 1.3; + font-size: 0.9375rem; + line-height: 1.4; color: var(--text-color); transition: border-color var(--transition-speed) var(--transition-ease), box-shadow var(--transition-speed) var(--transition-ease); @@ -554,8 +569,8 @@ textarea:focus { } .appointment-item textarea { - margin-left: 28px; - width: calc(100% - 28px); + margin-left: var(--checkbox-indent); + width: calc(100% - var(--checkbox-indent)); } /* ─── Settings card ─────────────────────────── */ @@ -589,13 +604,13 @@ textarea:focus { .settings-section-title { font-family: var(--font-display); - font-size: 0.95rem; + font-size: 1.0625rem; font-weight: 600; color: var(--text-color); margin: 0 0 var(--spacing-sm) 0; display: flex; align-items: center; - gap: 6px; + gap: var(--spacing-2xs); } .settings-section-title svg { @@ -620,13 +635,13 @@ textarea:focus { .color-grid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 16px; + gap: var(--spacing-md); } .color-option { display: flex; flex-direction: column; - gap: 6px; + gap: var(--spacing-2xs); } .color-option--wide { @@ -634,7 +649,7 @@ textarea:focus { } .color-label { - font-size: 0.72rem; + font-size: 0.8125rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; @@ -665,7 +680,7 @@ textarea:focus { .transparency-container { display: flex; align-items: center; - gap: 10px; + gap: var(--spacing-sm); } .transparency-slider { @@ -687,7 +702,7 @@ textarea:focus { background: white; border: 2px solid var(--primary-color); cursor: pointer; - box-shadow: 0 1px 4px rgba(0,0,0,0.1); + box-shadow: 0 1px 4px var(--shadow-color); transition: transform var(--transition-speed) var(--transition-ease); } @@ -702,24 +717,24 @@ textarea:focus { background: white; border: 2px solid var(--primary-color); cursor: pointer; - box-shadow: 0 1px 4px rgba(0,0,0,0.1); + box-shadow: 0 1px 4px var(--shadow-color); } .transparency-value { - font-size: 0.75rem; + font-size: 0.875rem; font-weight: 600; min-width: 36px; text-align: center; background-color: var(--primary-subtle); color: var(--primary-color); - padding: 4px 8px; - border-radius: 6px; + padding: 0.25em 0.5em; + border-radius: var(--spacing-2xs); } /* ─── Image sections ────────────────────────── */ .image-section-title { font-family: var(--font-display); - font-size: 0.95rem; + font-size: 1.0625rem; font-weight: 600; color: var(--text-color); margin-bottom: var(--spacing-sm); @@ -727,7 +742,7 @@ textarea:focus { } .image-preview { - margin-bottom: 10px; + margin-bottom: var(--spacing-sm); } .image-preview img { @@ -735,22 +750,22 @@ textarea:focus { max-width: 100%; border: 1px solid var(--border-color); border-radius: var(--border-radius); - padding: 4px; + padding: var(--spacing-xs); background: var(--primary-subtle); object-fit: cover; } .image-actions { display: flex; - gap: 8px; + gap: var(--spacing-sm); align-items: center; } .btn-upload { font-family: var(--font-family); - font-size: 0.8rem; + font-size: 0.875rem; font-weight: 600; - padding: 8px 16px; + padding: 0.5em 1em; border-radius: 100px; cursor: pointer; background: var(--primary-color); @@ -767,9 +782,9 @@ textarea:focus { .btn-delete-img { font-family: var(--font-family); - font-size: 0.8rem; + font-size: 0.875rem; font-weight: 600; - padding: 8px 16px; + padding: 0.5em 1em; border-radius: 100px; cursor: pointer; background: white; @@ -793,24 +808,24 @@ textarea:focus { z-index: 200; background: white; border-top: 1px solid var(--border-color); - box-shadow: 0 -2px 16px rgba(44, 51, 37, 0.08); - padding: 10px 16px; - padding-bottom: calc(10px + env(safe-area-inset-bottom)); + box-shadow: 0 -2px 16px var(--shadow-color); + padding: var(--spacing-sm) var(--spacing-md); + padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom)); text-align: center; } .bottom-bar-inner { display: flex; - gap: 10px; + gap: var(--spacing-sm); max-width: 720px; margin: 0 auto; } .bottom-bar-error { - font-size: 0.78rem; + font-size: 0.875rem; color: var(--danger-color); text-align: center; - margin-top: 6px; + margin-top: var(--spacing-2xs); } .btn-generate { @@ -819,9 +834,9 @@ textarea:focus { border: none; flex: 1; margin: 0; - padding: 14px 16px; + padding: 0.875em 1em; font-family: var(--font-family); - font-size: 0.88rem; + font-size: 0.9375rem; font-weight: 600; letter-spacing: 0.02em; border-radius: var(--border-radius); @@ -833,7 +848,7 @@ textarea:focus { .btn-generate .btn-icon { vertical-align: middle; - margin-right: 4px; + margin-right: var(--spacing-xs); } .btn-generate--secondary { @@ -859,7 +874,7 @@ textarea:focus { background-color: var(--primary-hover); color: white; transform: translateY(-1px); - box-shadow: 0 4px 18px rgba(94, 139, 90, 0.25); + box-shadow: var(--box-shadow-hover); } .btn-generate:disabled { @@ -879,13 +894,13 @@ footer { display: flex; align-items: center; justify-content: center; - gap: 10px; + gap: var(--spacing-sm); padding: var(--spacing-2xl) var(--spacing-xl); border: 2px dashed var(--border-color); border-radius: var(--container-radius); background-color: white; color: var(--text-muted); - font-size: 0.9rem; + font-size: 0.9375rem; } .appointments-loading .spinner-ring-inline { @@ -917,14 +932,14 @@ footer { } .empty-state-hint { - font-size: 0.9rem; + font-size: 0.9375rem; } /* ─── Inline button spinners ────────────────── */ .btn-spinner { display: inline-flex; align-items: center; - gap: 6px; + gap: var(--spacing-2xs); } .spinner-ring-inline { @@ -959,7 +974,7 @@ footer { padding: var(--spacing-lg); text-align: center; color: var(--text-muted); - font-size: 0.9rem; + font-size: 0.9375rem; font-style: italic; } @@ -984,12 +999,12 @@ footer { .date-row { flex-direction: row; - gap: 6px; + gap: var(--spacing-2xs); align-items: flex-end; } .date-separator { - padding-bottom: 10px; + padding-bottom: var(--spacing-sm); } .date-field { @@ -1003,8 +1018,8 @@ footer { .date-field input[type="text"] { width: 100%; - padding: 10px 10px; - font-size: 16px; /* prevents iOS zoom */ + padding: 0.625em; + font-size: 1rem; /* prevents iOS zoom */ } /* Fit all presets in one line on small screens */ @@ -1014,34 +1029,33 @@ footer { .preset-chip { flex: 1 1 0; - padding: 8px 8px; + padding: 0.5em; min-height: 36px; - font-size: 13px; + font-size: 0.8125rem; text-align: center; white-space: nowrap; } /* Calendar toggle bigger tap target */ .chips-toggle { - padding: 12px 14px; + padding: 0.75em 0.875em; min-height: 44px; } .chip-text { - padding: 8px 14px; - min-height: 38px; + padding: 0.6em 1em; } /* Fetch button bigger on mobile */ .btn-fetch { - padding: 16px 20px; + padding: 1em 1.25em; font-size: 1rem; min-height: 50px; } /* Appointment items */ .appointment-item { - padding: 6px 10px; + padding: var(--spacing-2xs) var(--spacing-sm); } .appointments-container { @@ -1051,21 +1065,21 @@ footer { /* Actions row */ .appointments-actions { - gap: 6px; + gap: var(--spacing-2xs); flex-wrap: nowrap; } .select-all-btn, .deselect-all-btn { - padding: 6px 10px; + padding: 0.375em 0.625em; min-height: 36px; - font-size: 0.72rem; + font-size: 0.8125rem; white-space: nowrap; } /* Color grid stays 3-col on mobile, just tighter */ .color-grid { - gap: 10px; + gap: var(--spacing-sm); } .color-input { @@ -1080,20 +1094,20 @@ footer { /* Bottom bar mobile */ .bottom-bar { - padding: 8px 12px; - padding-bottom: calc(8px + env(safe-area-inset-bottom)); + padding: var(--spacing-sm) 0.75em; + padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom)); } .btn-generate { - padding: 14px 12px; - font-size: 0.85rem; + padding: 0.875em 0.75em; + font-size: 0.9375rem; min-height: 48px; } /* Upload buttons bigger */ .btn-upload, .btn-delete-img { - padding: 10px 18px; + padding: 0.625em 1.125em; min-height: 40px; } @@ -1134,51 +1148,7 @@ footer { .container { width: 100%; max-width: 100%; - padding-left: 12px; - padding-right: 12px; + padding-left: 0.75em; + padding-right: 0.75em; } } - -/* ─── jQuery UI Datepicker overrides ────────── */ -.ui-datepicker { - font-family: var(--font-family) !important; - border: 1px solid var(--border-color) !important; - border-radius: var(--container-radius) !important; - box-shadow: var(--box-shadow-lg) !important; - padding: 8px !important; -} - -.ui-datepicker .ui-datepicker-header { - background: var(--primary-subtle) !important; - border: none !important; - border-radius: 10px !important; - font-family: var(--font-display) !important; - color: var(--text-color) !important; -} - -.ui-datepicker .ui-datepicker-title { - font-weight: 600 !important; -} - -.ui-datepicker td a.ui-state-default { - border: none !important; - border-radius: 8px !important; - text-align: center !important; - transition: all 0.15s ease !important; -} - -.ui-datepicker td a.ui-state-default:hover { - background: var(--primary-light) !important; - color: var(--primary-color) !important; -} - -.ui-datepicker td a.ui-state-active { - background: var(--primary-color) !important; - color: white !important; -} - -.ui-datepicker .ui-datepicker-prev, -.ui-datepicker .ui-datepicker-next { - cursor: pointer !important; - top: 6px !important; -} diff --git a/app/static/css/common.css b/app/static/css/common.css index f52ce04..ffac35f 100644 --- a/app/static/css/common.css +++ b/app/static/css/common.css @@ -21,6 +21,7 @@ --shadow-color: rgba(44, 51, 37, 0.08); /* Spacing */ + --spacing-2xs: 0.375rem; --spacing-xs: 0.25rem; --spacing-sm: 0.5rem; --spacing-md: 1rem; @@ -33,12 +34,13 @@ --container-radius: 16px; --box-shadow: 0 1px 3px rgba(44, 51, 37, 0.05), 0 4px 14px rgba(44, 51, 37, 0.04); --box-shadow-lg: 0 4px 20px rgba(44, 51, 37, 0.08), 0 10px 40px rgba(44, 51, 37, 0.04); + --box-shadow-hover: 0 4px 18px rgba(94, 139, 90, 0.25); --focus-ring: 0 0 0 3px rgba(94, 139, 90, 0.25); /* Typography */ --font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; --font-display: 'Fraunces', Georgia, serif; - --font-size-base: 15px; + --font-size-base: 16px; --line-height: 1.6; /* Transitions */ @@ -127,7 +129,7 @@ label { margin-top: var(--spacing-lg); margin-bottom: var(--spacing-xs); font-weight: 600; - font-size: 0.85rem; + font-size: 0.875rem; color: var(--text-muted); text-align: left; text-transform: uppercase; @@ -146,7 +148,7 @@ select { border: 1px solid var(--border-color); border-radius: var(--border-radius); font-family: var(--font-family); - font-size: 0.95rem; + font-size: 1rem; color: var(--text-color); background: white; transition: border-color var(--transition-speed) var(--transition-ease), @@ -172,7 +174,7 @@ input[type="submit"] { border-radius: var(--border-radius); color: white; font-family: var(--font-family); - font-size: 0.9rem; + font-size: 1rem; font-weight: 600; cursor: pointer; transition: background-color var(--transition-speed) var(--transition-ease), @@ -209,7 +211,7 @@ input[type="submit"]:active { padding: 0.65rem 1.1rem; margin-top: var(--spacing-md); font-family: var(--font-family); - font-size: 0.9rem; + font-size: 0.9375rem; font-weight: 600; color: white; text-decoration: none; @@ -249,7 +251,7 @@ input[type="submit"]:active { box-shadow: none; } -.btn:focus { +.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); } @@ -313,6 +315,8 @@ footer { } /* Utility classes */ +.hidden { display: none !important; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .text-center { text-align: center; } .text-left { text-align: left; } .text-right { text-align: right; } @@ -328,7 +332,6 @@ footer { /* Responsive adjustments */ @media (max-width: 768px) { - html { font-size: 14px; } .container { padding: var(--spacing-lg); max-width: 100%; diff --git a/app/static/css/flatpickr-theme.css b/app/static/css/flatpickr-theme.css new file mode 100644 index 0000000..5835dc1 --- /dev/null +++ b/app/static/css/flatpickr-theme.css @@ -0,0 +1,72 @@ +/* flatpickr-theme.css — Sage green overrides */ + +.flatpickr-calendar { + font-family: var(--font-family); + border: 1px solid var(--border-color); + border-radius: var(--container-radius); + box-shadow: var(--box-shadow-lg); +} + +.flatpickr-months { + border-radius: var(--container-radius) var(--container-radius) 0 0; + background: var(--primary-subtle); +} + +.flatpickr-months .flatpickr-month { + background: transparent; + color: var(--text-color); + font-family: var(--font-display); +} + +.flatpickr-current-month .flatpickr-monthDropdown-months, +.flatpickr-current-month input.cur-year { + font-family: var(--font-display); + font-weight: 600; + color: var(--text-color); +} + +.flatpickr-months .flatpickr-prev-month, +.flatpickr-months .flatpickr-next-month { + color: var(--text-muted); + fill: var(--text-muted); +} + +.flatpickr-months .flatpickr-prev-month:hover, +.flatpickr-months .flatpickr-next-month:hover { + color: var(--primary-color); + fill: var(--primary-color); +} + +span.flatpickr-weekday { + color: var(--text-muted); + font-weight: 600; + font-size: 0.78rem; +} + +.flatpickr-day { + border-radius: 8px; + color: var(--text-color); + transition: all 0.15s ease; +} + +.flatpickr-day:hover { + background: var(--primary-light); + border-color: var(--primary-light); + color: var(--primary-color); +} + +.flatpickr-day.selected, +.flatpickr-day.selected:hover { + background: var(--primary-color); + border-color: var(--primary-color); + color: white; +} + +.flatpickr-day.today { + border-color: var(--primary-color); +} + +.flatpickr-day.today:hover { + background: var(--primary-color); + color: white; +} diff --git a/app/static/css/flatpickr.min.css b/app/static/css/flatpickr.min.css new file mode 100644 index 0000000..a10acc6 --- /dev/null +++ b/app/static/css/flatpickr.min.css @@ -0,0 +1,13 @@ +.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/* + /*rtl:begin:ignore*/left:0/* + /*rtl:end:ignore*/}/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,.flatpickr-months .flatpickr-next-month.flatpickr-next-month{/* + /*rtl:begin:ignore*/right:0/* + /*rtl:end:ignore*/}/* + /*rtl:begin:ignore*/ +/* + /*rtl:end:ignore*/ +.flatpickr-months .flatpickr-prev-month:hover,.flatpickr-months .flatpickr-next-month:hover{color:#959ea9}.flatpickr-months .flatpickr-prev-month:hover svg,.flatpickr-months .flatpickr-next-month:hover svg{fill:#f64747}.flatpickr-months .flatpickr-prev-month svg,.flatpickr-months .flatpickr-next-month svg{width:14px;height:14px}.flatpickr-months .flatpickr-prev-month svg path,.flatpickr-months .flatpickr-next-month svg path{-webkit-transition:fill .1s;transition:fill .1s;fill:inherit}.numInputWrapper{position:relative;height:auto}.numInputWrapper input,.numInputWrapper span{display:inline-block}.numInputWrapper input{width:100%}.numInputWrapper input::-ms-clear{display:none}.numInputWrapper input::-webkit-outer-spin-button,.numInputWrapper input::-webkit-inner-spin-button{margin:0;-webkit-appearance:none}.numInputWrapper span{position:absolute;right:0;width:14px;padding:0 4px 0 2px;height:50%;line-height:50%;opacity:0;cursor:pointer;border:1px solid rgba(57,57,57,0.15);-webkit-box-sizing:border-box;box-sizing:border-box}.numInputWrapper span:hover{background:rgba(0,0,0,0.1)}.numInputWrapper span:active{background:rgba(0,0,0,0.2)}.numInputWrapper span:after{display:block;content:"";position:absolute}.numInputWrapper span.arrowUp{top:0;border-bottom:0}.numInputWrapper span.arrowUp:after{border-left:4px solid transparent;border-right:4px solid transparent;border-bottom:4px solid rgba(57,57,57,0.6);top:26%}.numInputWrapper span.arrowDown{top:50%}.numInputWrapper span.arrowDown:after{border-left:4px solid transparent;border-right:4px solid transparent;border-top:4px solid rgba(57,57,57,0.6);top:40%}.numInputWrapper span svg{width:inherit;height:auto}.numInputWrapper span svg path{fill:rgba(0,0,0,0.5)}.numInputWrapper:hover{background:rgba(0,0,0,0.05)}.numInputWrapper:hover span{opacity:1}.flatpickr-current-month{font-size:135%;line-height:inherit;font-weight:300;color:inherit;position:absolute;width:75%;left:12.5%;padding:7.48px 0 0 0;line-height:1;height:34px;display:inline-block;text-align:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.flatpickr-current-month span.cur-month{font-family:inherit;font-weight:700;color:inherit;display:inline-block;margin-left:.5ch;padding:0}.flatpickr-current-month span.cur-month:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .numInputWrapper{width:6ch;width:7ch\0;display:inline-block}.flatpickr-current-month .numInputWrapper span.arrowUp:after{border-bottom-color:rgba(0,0,0,0.9)}.flatpickr-current-month .numInputWrapper span.arrowDown:after{border-top-color:rgba(0,0,0,0.9)}.flatpickr-current-month input.cur-year{background:transparent;-webkit-box-sizing:border-box;box-sizing:border-box;color:inherit;cursor:text;padding:0 0 0 .5ch;margin:0;display:inline-block;font-size:inherit;font-family:inherit;font-weight:300;line-height:inherit;height:auto;border:0;border-radius:0;vertical-align:initial;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-current-month input.cur-year:focus{outline:0}.flatpickr-current-month input.cur-year[disabled],.flatpickr-current-month input.cur-year[disabled]:hover{font-size:100%;color:rgba(0,0,0,0.5);background:transparent;pointer-events:none}.flatpickr-current-month .flatpickr-monthDropdown-months{appearance:menulist;background:transparent;border:none;border-radius:0;box-sizing:border-box;color:inherit;cursor:pointer;font-size:inherit;font-family:inherit;font-weight:300;height:auto;line-height:inherit;margin:-1px 0 0 0;outline:none;padding:0 0 0 .5ch;position:relative;vertical-align:initial;-webkit-box-sizing:border-box;-webkit-appearance:menulist;-moz-appearance:menulist;width:auto}.flatpickr-current-month .flatpickr-monthDropdown-months:focus,.flatpickr-current-month .flatpickr-monthDropdown-months:active{outline:none}.flatpickr-current-month .flatpickr-monthDropdown-months:hover{background:rgba(0,0,0,0.05)}.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month{background-color:transparent;outline:none;padding:0}.flatpickr-weekdays{background:transparent;text-align:center;overflow:hidden;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:28px}.flatpickr-weekdays .flatpickr-weekdaycontainer{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}span.flatpickr-weekday{cursor:default;font-size:90%;background:transparent;color:rgba(0,0,0,0.54);line-height:1;margin:0;text-align:center;display:block;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;font-weight:bolder}.dayContainer,.flatpickr-weeks{padding:1px 0 0 0}.flatpickr-days{position:relative;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:start;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;width:307.875px}.flatpickr-days:focus{outline:0}.dayContainer{padding:0;outline:0;text-align:left;width:307.875px;min-width:307.875px;max-width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;display:inline-block;display:-ms-flexbox;display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-wrap:wrap;-ms-flex-pack:justify;-webkit-justify-content:space-around;justify-content:space-around;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);opacity:1}.dayContainer + .dayContainer{-webkit-box-shadow:-1px 0 0 #e6e6e6;box-shadow:-1px 0 0 #e6e6e6}.flatpickr-day{background:none;border:1px solid transparent;border-radius:150px;-webkit-box-sizing:border-box;box-sizing:border-box;color:#393939;cursor:pointer;font-weight:400;width:14.2857143%;-webkit-flex-basis:14.2857143%;-ms-flex-preferred-size:14.2857143%;flex-basis:14.2857143%;max-width:39px;height:39px;line-height:39px;margin:0;display:inline-block;position:relative;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;text-align:center}.flatpickr-day.inRange,.flatpickr-day.prevMonthDay.inRange,.flatpickr-day.nextMonthDay.inRange,.flatpickr-day.today.inRange,.flatpickr-day.prevMonthDay.today.inRange,.flatpickr-day.nextMonthDay.today.inRange,.flatpickr-day:hover,.flatpickr-day.prevMonthDay:hover,.flatpickr-day.nextMonthDay:hover,.flatpickr-day:focus,.flatpickr-day.prevMonthDay:focus,.flatpickr-day.nextMonthDay:focus{cursor:pointer;outline:0;background:#e6e6e6;border-color:#e6e6e6}.flatpickr-day.today{border-color:#959ea9}.flatpickr-day.today:hover,.flatpickr-day.today:focus{border-color:#959ea9;background:#959ea9;color:#fff}.flatpickr-day.selected,.flatpickr-day.startRange,.flatpickr-day.endRange,.flatpickr-day.selected.inRange,.flatpickr-day.startRange.inRange,.flatpickr-day.endRange.inRange,.flatpickr-day.selected:focus,.flatpickr-day.startRange:focus,.flatpickr-day.endRange:focus,.flatpickr-day.selected:hover,.flatpickr-day.startRange:hover,.flatpickr-day.endRange:hover,.flatpickr-day.selected.prevMonthDay,.flatpickr-day.startRange.prevMonthDay,.flatpickr-day.endRange.prevMonthDay,.flatpickr-day.selected.nextMonthDay,.flatpickr-day.startRange.nextMonthDay,.flatpickr-day.endRange.nextMonthDay{background:#569ff7;-webkit-box-shadow:none;box-shadow:none;color:#fff;border-color:#569ff7}.flatpickr-day.selected.startRange,.flatpickr-day.startRange.startRange,.flatpickr-day.endRange.startRange{border-radius:50px 0 0 50px}.flatpickr-day.selected.endRange,.flatpickr-day.startRange.endRange,.flatpickr-day.endRange.endRange{border-radius:0 50px 50px 0}.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)){-webkit-box-shadow:-10px 0 0 #569ff7;box-shadow:-10px 0 0 #569ff7}.flatpickr-day.selected.startRange.endRange,.flatpickr-day.startRange.startRange.endRange,.flatpickr-day.endRange.startRange.endRange{border-radius:50px}.flatpickr-day.inRange{border-radius:0;-webkit-box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-5px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover,.flatpickr-day.prevMonthDay,.flatpickr-day.nextMonthDay,.flatpickr-day.notAllowed,.flatpickr-day.notAllowed.prevMonthDay,.flatpickr-day.notAllowed.nextMonthDay{color:rgba(57,57,57,0.3);background:transparent;border-color:transparent;cursor:default}.flatpickr-day.flatpickr-disabled,.flatpickr-day.flatpickr-disabled:hover{cursor:not-allowed;color:rgba(57,57,57,0.1)}.flatpickr-day.week.selected{border-radius:0;-webkit-box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7;box-shadow:-5px 0 0 #569ff7,5px 0 0 #569ff7}.flatpickr-day.hidden{visibility:hidden}.rangeMode .flatpickr-day{margin-top:1px}.flatpickr-weekwrapper{float:left}.flatpickr-weekwrapper .flatpickr-weeks{padding:0 12px;-webkit-box-shadow:1px 0 0 #e6e6e6;box-shadow:1px 0 0 #e6e6e6}.flatpickr-weekwrapper .flatpickr-weekday{float:none;width:100%;line-height:28px}.flatpickr-weekwrapper span.flatpickr-day,.flatpickr-weekwrapper span.flatpickr-day:hover{display:block;width:100%;max-width:none;color:rgba(57,57,57,0.3);background:transparent;cursor:default;border:none}.flatpickr-innerContainer{display:block;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.flatpickr-rContainer{display:inline-block;padding:0;-webkit-box-sizing:border-box;box-sizing:border-box}.flatpickr-time{text-align:center;outline:0;display:block;height:0;line-height:40px;max-height:40px;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-time:after{content:"";display:table;clear:both}.flatpickr-time .numInputWrapper{-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1;width:40%;height:40px;float:left}.flatpickr-time .numInputWrapper span.arrowUp:after{border-bottom-color:#393939}.flatpickr-time .numInputWrapper span.arrowDown:after{border-top-color:#393939}.flatpickr-time.hasSeconds .numInputWrapper{width:26%}.flatpickr-time.time24hr .numInputWrapper{width:49%}.flatpickr-time input{background:transparent;-webkit-box-shadow:none;box-shadow:none;border:0;border-radius:0;text-align:center;margin:0;padding:0;height:inherit;line-height:inherit;color:#393939;font-size:14px;position:relative;-webkit-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:textfield;-moz-appearance:textfield;appearance:textfield}.flatpickr-time input.flatpickr-hour{font-weight:bold}.flatpickr-time input.flatpickr-minute,.flatpickr-time input.flatpickr-second{font-weight:400}.flatpickr-time input:focus{outline:0;border:0}.flatpickr-time .flatpickr-time-separator,.flatpickr-time .flatpickr-am-pm{height:inherit;float:left;line-height:inherit;color:#393939;font-weight:bold;width:2%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.flatpickr-time .flatpickr-am-pm{outline:0;width:18%;cursor:pointer;text-align:center;font-weight:400}.flatpickr-time input:hover,.flatpickr-time .flatpickr-am-pm:hover,.flatpickr-time input:focus,.flatpickr-time .flatpickr-am-pm:focus{background:#eee}.flatpickr-input[readonly]{cursor:pointer}@-webkit-keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}@keyframes fpFadeInDown{from{opacity:0;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}} \ No newline at end of file diff --git a/app/static/js/alpine.min.js b/app/static/js/alpine.min.js new file mode 100644 index 0000000..a3be81c --- /dev/null +++ b/app/static/js/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var nt=!1,it=!1,W=[],ot=-1;function Ut(e){Rn(e)}function Rn(e){W.includes(e)||W.push(e),Mn()}function Wt(e){let t=W.indexOf(e);t!==-1&&t>ot&&W.splice(t,1)}function Mn(){!it&&!nt&&(nt=!0,queueMicrotask(Nn))}function Nn(){nt=!1,it=!0;for(let e=0;ee.effect(t,{scheduler:r=>{st?Ut(r):r()}}),at=e.raw}function ct(e){N=e}function Yt(e){let t=()=>{};return[n=>{let i=N(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),$(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=N(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>$(i)}var Xt=[],Zt=[],Qt=[];function er(e){Qt.push(e)}function te(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Zt.push(t))}function Ae(e){Xt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function lt(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function tr(e){for(e._x_effects?.forEach(Wt);e._x_cleanups?.length;)e._x_cleanups.pop()()}var ut=new MutationObserver(mt),ft=!1;function ue(){ut.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ft=!0}function dt(){kn(),ut.disconnect(),ft=!1}var le=[];function kn(){let e=ut.takeRecords();le.push(()=>e.length>0&&mt(e));let t=le.length;queueMicrotask(()=>{if(le.length===t)for(;le.length>0;)le.shift()()})}function m(e){if(!ft)return e();dt();let t=e();return ue(),t}var pt=!1,Se=[];function rr(){pt=!0}function nr(){pt=!1,mt(Se),Se=[]}function mt(e){if(pt){Se=Se.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{lt(s,o)}),n.forEach((o,s)=>{Xt.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Zt.forEach(s=>s(o));for(let o of t)o.isConnected&&Qt.forEach(s=>s(o));t=null,r=null,n=null,i=null}function Ce(e){return z(B(e))}function k(e,t,r){return e._x_dataStack=[t,...B(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function B(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?B(e.host):e.parentNode?B(e.parentNode):[]}function z(e){return new Proxy({objects:e},Dn)}var Dn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Pn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Pn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>In(n,i),s=>ht(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function In(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function ht(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),ht(e[t[0]],t.slice(1),r)}}var ir={};function y(e,t){ir[e]=t}function fe(e,t){let r=Ln(t);return Object.entries(ir).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function Ln(e){let[t,r]=_t(e),n={interceptor:Re,...t};return te(e,r),n}function or(e,t,r,...n){try{return r(...n)}catch(i){re(i,e,t)}}function re(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function ke(e){let t=Me;Me=!1;let r=e();return Me=t,r}function R(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return sr(...e)}var sr=xt;function ar(e){sr=e}function xt(e,t){let r={};fe(r,e);let n=[r,...B(e)],i=typeof t=="function"?$n(n,t):Fn(n,t,e);return or.bind(null,e,t,i)}function $n(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(z([n,...e]),i);Ne(r,o)}}var gt={};function jn(e,t){if(gt[e])return gt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return re(s,t,e),Promise.resolve()}})();return gt[e]=o,o}function Fn(e,t,r){let n=jn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=z([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>re(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>re(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>re(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var wt="x-";function C(e=""){return wt+e}function cr(e){wt=e}var De={};function d(e,t){return De[e]=t,{before(r){if(!De[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,e)}}}function lr(e){return Object.keys(De).includes(e)}function pe(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=Et(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(dr((o,s)=>n[o]=s)).filter(mr).map(zn(n,r)).sort(Kn).map(o=>Bn(e,o))}function Et(e){return Array.from(e).map(dr()).filter(t=>!mr(t))}var yt=!1,de=new Map,ur=Symbol();function fr(e){yt=!0;let t=Symbol();ur=t,de.set(t,[]);let r=()=>{for(;de.get(t).length;)de.get(t).shift()();de.delete(t)},n=()=>{yt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Yt(e);return t.push(i),[{Alpine:K,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:R.bind(R,e)},()=>t.forEach(a=>a())]}function Bn(e,t){let r=()=>{},n=De[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),yt?de.get(ur).push(n):n())};return s.runCleanups=o,s}var Pe=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Ie=e=>e;function dr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=pr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var pr=[];function ne(e){pr.push(e)}function mr({name:e}){return hr().test(e)}var hr=()=>new RegExp(`^${wt}([^:^.]+)\\b`);function zn(e,t){return({name:r,value:n})=>{let i=r.match(hr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var bt="DEFAULT",G=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",bt,"teleport"];function Kn(e,t){let r=G.indexOf(e.type)===-1?bt:e.type,n=G.indexOf(t.type)===-1?bt:t.type;return G.indexOf(r)-G.indexOf(n)}function J(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function D(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>D(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)D(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var _r=!1;function gr(){_r&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),_r=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` - + + + + + + @@ -65,7 +74,7 @@

Terminübersicht

{{ selected_calendar_ids|length }} von {{ calendars|length }} -