diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..fc7738c --- /dev/null +++ b/.coveragerc @@ -0,0 +1,21 @@ +[run] +branch = True +source = + shiftings +plugins = + django_coverage_plugin + +[report] +show_missing = True +skip_covered = False +omit = + */migrations/* + */test_*.py + */tests/* + */manage.py + +[html] +directory = coverage_html + +[django_coverage_plugin] +template_extensions = html,htm,txt diff --git a/.gitignore b/.gitignore index 1f2dbb0..b121fe1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ gunicorn.py /build/ /src/shiftings/templates/local/ -ruff.toml \ No newline at end of file +ruff.toml +.coverage +/mutants/ +noir.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9b7525..4b07be0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: check-toml - id: check-json - id: pretty-format-json - args: [--auto-fix] + args: [--autofix, --no-sort-keys] - id: check-merge-conflict - id: check-symlinks - id: destroyed-symlinks diff --git a/README.md b/README.md index 030bd3b..feae614 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,10 @@ See [Django docs](https://docs.djangoproject.com/en/4.1/howto/deployment/) for d 1. Add a local_settings.py. See local_settings.sample.py for inspiration. 1. Add a cron, systemd timer unit or similar for recurring shift creation +#### Testing + +Testing quality beyond line coverage is documented in the [Testing Quality Scorecard](docs/testing_quality.md). + ## Authors [lewellien](https://github.com/lewellien) & [Tjeri](https://github.com/tjeri) diff --git a/docs/development.md b/docs/development.md index 1cb1c16..2fe7076 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,3 +1,5 @@ # Development For information on development, refer to the project [README](https://github.com/HaDiNet/shiftings/). + +Testing quality beyond line coverage is documented in the [Testing Quality Scorecard](testing_quality.md). diff --git a/docs/regression_protection_tracker.json b/docs/regression_protection_tracker.json new file mode 100644 index 0000000..4d6fbe7 --- /dev/null +++ b/docs/regression_protection_tracker.json @@ -0,0 +1,20 @@ +{ + "tier1_fixes": [ + { + "id": "2026-04-17-shift-template-group-org-pk-resolution", + "description": "Fix missing-pk create flow by resolving organization from org_pk path kwarg.", + "has_regression_test": true, + "test_references": [ + "src/shiftings/shifts/tests/test_template_views.py" + ] + }, + { + "id": "2026-04-18-token-validation-hardening", + "description": "Harden token validation behavior and add deterministic tests for failure paths.", + "has_regression_test": true, + "test_references": [ + "src/shiftings/accounts/tests/test_token_security.py" + ] + } + ] +} diff --git a/docs/testing_quality.md b/docs/testing_quality.md new file mode 100644 index 0000000..2f49d9d --- /dev/null +++ b/docs/testing_quality.md @@ -0,0 +1,119 @@ +# Testing Quality Scorecard + +This scorecard complements line coverage with behavior-focused indicators. + +## Why + +Line coverage answers: "Did code run?" + +This scorecard answers: "Would tests catch bad behavior?" + +## Current Baseline (2026-04-17) + +- Full-suite line coverage: 41% +- Critical auth behavior coverage: + - accounts/views/auth.py: 98% + - accounts/views/user.py: 97% +- Core shared view mixin coverage: + - utils/views/base.py: 98% + +## Behavior Quality Metrics + +Use these together. No single metric is enough. + +### 1) Risk-Weighted Coverage + +Track coverage only for high-risk modules first (auth, permission, shift lifecycle). + +Suggested risk tiers: + +- Tier 1 (critical): authentication, authorization, user identity, participation permissions +- Tier 2 (important): shift creation/update/delete and recurring generation +- Tier 3 (supporting): helpers and UI integration + +### 2) Scenario Coverage + +Maintain a behavior matrix and mark each scenario as: + +- Covered: at least one deterministic automated test +- Partial: path exists but edge/error branches missing +- Missing: no automated coverage + +#### Scenario Matrix + +| Domain | Scenario | Status | Test Reference | +|---|---|---|---| +| Auth | Local login rendering and fallback flows | Covered | `accounts/tests/test_auth_views.py` | +| Auth | SSO login initiation (enabled/disabled) | Covered | `accounts/tests/test_auth_views.py` | +| Auth | OAuth callback success/failure/exception | Covered | `accounts/tests/test_auth_views.py` | +| Auth | Confirm-email valid/invalid/malformed token paths | Covered | `accounts/tests/test_user_views.py` | +| Auth | Logout and relogin cache/session edge behavior | Partial | `accounts/tests/test_auth_views.py` | +| Auth | Password-reset confirmation invalid/expired token behavior | Missing | `n/a` | +| Auth | Brute-force and repeated failed-login throttling behavior | Missing | `n/a` | +| Permissions | Missing-permission behavior (redirect, 403, fail URL) | Covered | `utils/tests/test_base_views.py` | +| Permissions | Shift participation permission update workflow | Covered | `shifts/tests/test_permission_views.py` | +| Permissions | Duplicate organization rejection in permission formset | Covered | `shifts/tests/test_permission_forms.py` | +| Permissions | Cross-organization isolation for permission edits | Missing | `n/a` | +| Permissions | Permission precedence conflicts across global/org scopes | Partial | `shifts/tests/test_permission_forms.py` | +| Shifts | Participant add/remove for self and others | Covered | `shifts/tests/test_participant_views.py` | +| Shifts | Shift CRUD edge cases (date bounds, authz, redirects) | Partial | `shifts/tests/*` | +| Shifts | Summary view filtering and boundary scenarios | Missing | `n/a` | +| Shifts | Concurrent participant slot contention and max-user enforcement | Missing | `n/a` | +| Shifts | Shift copy/template creation with invalid source references | Missing | `n/a` | +| Recurring shifts | Form validation for recurring setup | Partial | `shifts/tests/*` | +| Recurring shifts | Recurring generation command and date edge cases | Missing | `n/a` | +| Recurring shifts | Timezone and DST boundary generation correctness | Missing | `n/a` | +| Recurring shifts | Idempotency of repeated recurring generation runs | Missing | `n/a` | +| Mail | Attachment size and date-range validation | Covered | `mail/tests/test_mail_forms.py` | +| Mail | Mail view send flows and permission boundaries | Partial | `mail/tests/*` | +| Mail | Delivery failure/retry behavior and user-visible error states | Missing | `n/a` | +| Mail | Recipient resolution with mixed member/group filters | Partial | `mail/tests/*` | +| Utils | Protected media header dispatch by backend type | Covered | `utils/tests/test_protected_content.py` | +| Utils | Saved-path breadcrumb behavior with complex query params | Partial | `utils/tests/test_base_views.py` | +| Utils | Http403 middleware integration and custom error page wiring | Missing | `n/a` | + +### 3) Mutation Score (Recommended) + +Measure if tests fail when implementation is intentionally perturbed. + +Target: + +- Tier 1 modules: > 70% +- Tier 2 modules: > 55% + +Note: + +- Start with small scope to keep runtime manageable. +- Prioritize accounts/views/auth.py, utils/views/base.py, shifts/views/shift.py. + +### 4) Regression Protection Index + +For each bug fix, add a test that fails before the fix. + +Track monthly: + +- Bugs fixed with regression test / total bugs fixed + +Target: + +- 100% for Tier 1 bugs + +## Scorecard Template + +Update this table in each release cycle. + +| Metric | Current | Target | Trend | +|---|---:|---:|---| +| Full-suite line coverage | 41% | 50% | up | +| Tier 1 weighted coverage | n/a | 75% | n/a | +| Scenario coverage (Tier 1) | 8/13 | 10/13 | up | +| Mutation score (Tier 1) | n/a | 70% | n/a | +| Regression protection index (Tier 1) | n/a | 100% | n/a | + +## Update Procedure + +1. Run full tests with coverage. +2. Update Tier 1/Tier 2 module coverage values. +3. Review scenario matrix and mark new covered paths. +4. Record newly fixed bugs and whether regression tests exist. +5. Update scorecard table and share in release notes. diff --git a/mkdocs.yml b/mkdocs.yml index 266344a..17463f9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,4 +45,6 @@ nav: - Shift Summary: 'organizations/shift_summary.md' - Events: - Coming Soon: 'events/index.md' - - Development: 'development.md' \ No newline at end of file + - Development: + - Overview: 'development.md' + - Testing Quality Scorecard: 'testing_quality.md' diff --git a/pyproject.toml b/pyproject.toml index dcfd8f6..18c65ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,12 +55,15 @@ dev = [ 'types-python-dateutil==2.9.0.20260124', 'types-requests==2.32.4.20260107', 'mypy==1.19.1', + 'django-extensions==4.1', ] tests = [ 'pytest==9.0.2', 'pytest-django==4.12.0', 'pytest-cov==7.0.0', + 'django-coverage-plugin==3.2.2', + 'mutmut==3.3.1', ] docs = [ @@ -79,3 +82,25 @@ where = ['src'] [tool.setuptools.package-data] shiftings = ['py.typed'] + +[tool.mutmut] +paths_to_mutate = [ + 'src/shiftings/accounts/views/auth.py', + 'src/shiftings/accounts/views/user.py', + 'src/shiftings/shifts/forms/permission.py', + 'src/shiftings/shifts/views/permission.py', + 'src/shiftings/utils/views/base.py', +] +tests_dir = [ + 'src/shiftings/accounts/tests', + 'src/shiftings/shifts/tests', + 'src/shiftings/utils/tests', +] +also_copy = [ + 'src', +] +pytest_add_cli_args = [ + '--ds=shiftings.settings', + '-q', +] +mutate_only_covered_lines = true diff --git a/scripts/calc_testing_metrics.py b/scripts/calc_testing_metrics.py new file mode 100644 index 0000000..feff4b7 --- /dev/null +++ b/scripts/calc_testing_metrics.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +TIER1_COVERAGE_MODULES = ( + 'shiftings/accounts/views/auth.py', + 'shiftings/accounts/views/user.py', + 'shiftings/shifts/forms/permission.py', + 'shiftings/shifts/views/permission.py', + 'shiftings/utils/views/base.py', +) + +TIER1_SCENARIO_DOMAINS = {'Auth', 'Permissions'} + + +@dataclass +class CoverageRow: + stmts: int + miss: int + cover_pct: float + + @property + def covered(self) -> int: + return self.stmts - self.miss + + +def parse_coverage_report(report_text: str) -> dict[str, CoverageRow]: + rows: dict[str, CoverageRow] = {} + line_re = re.compile(r'^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)%\s*$') + + for line in report_text.splitlines(): + match = line_re.match(line.strip()) + if not match: + continue + path, stmts, miss, cover_pct = match.groups() + rows[path] = CoverageRow(stmts=int(stmts), miss=int(miss), cover_pct=float(cover_pct)) + + return rows + + +def run_coverage_report(repo_root: Path) -> str: + result = subprocess.run( + ['python', '-m', 'coverage', 'report'], + cwd=repo_root / 'src', + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +def parse_scenario_coverage(doc_text: str) -> tuple[int, int, int, int]: + covered = 0 + partial = 0 + missing = 0 + total = 0 + + for line in doc_text.splitlines(): + if not line.startswith('|'): + continue + cells = [cell.strip() for cell in line.strip().strip('|').split('|')] + if len(cells) < 4: + continue + domain = cells[0] + status = cells[2] + if domain not in TIER1_SCENARIO_DOMAINS: + continue + total += 1 + if status == 'Covered': + covered += 1 + elif status == 'Partial': + partial += 1 + elif status == 'Missing': + missing += 1 + + return covered, partial, missing, total + + +def load_mutation_score(repo_root: Path) -> tuple[float | None, dict[str, int] | None]: + stats_path = repo_root / 'mutants' / 'mutmut-cicd-stats.json' + if not stats_path.exists(): + return None, None + + data = json.loads(stats_path.read_text(encoding='utf-8')) + killed = int(data.get('killed', 0)) + survived = int(data.get('survived', 0)) + + denominator = killed + survived + if denominator == 0: + return None, data + return (100.0 * killed / denominator), data + + +def load_regression_index(repo_root: Path, tracker_path: Path) -> tuple[float | None, int, int]: + path = tracker_path if tracker_path.is_absolute() else (repo_root / tracker_path) + if not path.exists(): + return None, 0, 0 + + data = json.loads(path.read_text(encoding='utf-8')) + items = data.get('tier1_fixes', []) + total = len(items) + if total == 0: + return None, 0, 0 + + with_test = sum(1 for item in items if bool(item.get('has_regression_test'))) + return (100.0 * with_test / total), with_test, total + + +def maybe_run_mutmut(repo_root: Path) -> None: + (repo_root / 'mutants').mkdir(parents=True, exist_ok=True) + env = dict(**os.environ) + env['PYTHONPATH'] = 'src' + try: + subprocess.run(['python', '-m', 'mutmut', 'run'], cwd=repo_root, check=True, env=env) + subprocess.run(['python', '-m', 'mutmut', 'export-cicd-stats'], cwd=repo_root, check=True, env=env) + except subprocess.CalledProcessError as exc: + raise SystemExit( + 'mutmut execution failed. If you are running on Python 3.14, run mutmut in a 3.12/3.13 ' + 'environment and then rerun this script without --run-mutmut.' + ) from exc + + +def main() -> int: + parser = argparse.ArgumentParser(description='Calculate testing quality scorecard metrics.') + parser.add_argument('--repo-root', default='.', help='Path to repository root.') + parser.add_argument('--doc', default='docs/testing_quality.md', help='Path to testing quality markdown file.') + parser.add_argument( + '--regression-tracker', + default='docs/regression_protection_tracker.json', + help='JSON file tracking Tier 1 bug fixes and regression tests.', + ) + parser.add_argument('--coverage-report-file', help='Use an existing coverage report text file instead of running coverage.') + parser.add_argument('--run-mutmut', action='store_true', help='Run mutmut before reading mutation score.') + parser.add_argument('--json', action='store_true', help='Print output as JSON instead of markdown-friendly text.') + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + doc_path = Path(args.doc) if Path(args.doc).is_absolute() else (repo_root / args.doc) + + if args.run_mutmut: + maybe_run_mutmut(repo_root) + + if args.coverage_report_file: + report_path = Path(args.coverage_report_file) + if not report_path.is_absolute(): + report_path = repo_root / report_path + coverage_report = report_path.read_text(encoding='utf-8') + else: + coverage_report = run_coverage_report(repo_root) + + coverage_rows = parse_coverage_report(coverage_report) + + tier1_rows = [coverage_rows[module] for module in TIER1_COVERAGE_MODULES if module in coverage_rows] + if not tier1_rows: + raise SystemExit('No Tier 1 module coverage rows found. Run coverage first.') + + tier1_total_stmts = sum(row.stmts for row in tier1_rows) + tier1_total_covered = sum(row.covered for row in tier1_rows) + tier1_weighted_pct = 100.0 * tier1_total_covered / tier1_total_stmts + + total_coverage = coverage_rows.get('TOTAL') + if total_coverage is None: + raise SystemExit('TOTAL row missing from coverage report.') + + doc_text = doc_path.read_text(encoding='utf-8') + covered, partial, missing, total = parse_scenario_coverage(doc_text) + scenario_strict_pct = (100.0 * covered / total) if total else None + scenario_partial_weighted_pct = (100.0 * (covered + 0.5 * partial) / total) if total else None + + mutation_score_pct, mutation_raw = load_mutation_score(repo_root) + regression_pct, regression_with_test, regression_total = load_regression_index( + repo_root, Path(args.regression_tracker) + ) + + result = { + 'full_suite_coverage_pct': round(total_coverage.cover_pct, 1), + 'tier1_weighted_coverage_pct': round(tier1_weighted_pct, 1), + 'tier1_scenario_covered': covered, + 'tier1_scenario_total': total, + 'tier1_scenario_strict_pct': None if scenario_strict_pct is None else round(scenario_strict_pct, 1), + 'tier1_scenario_partial_weighted_pct': None + if scenario_partial_weighted_pct is None + else round(scenario_partial_weighted_pct, 1), + 'tier1_mutation_score_pct': None if mutation_score_pct is None else round(mutation_score_pct, 1), + 'tier1_regression_protection_pct': None if regression_pct is None else round(regression_pct, 1), + 'tier1_regression_with_test': regression_with_test, + 'tier1_regression_total': regression_total, + 'tier1_modules': { + module: { + 'stmts': coverage_rows[module].stmts, + 'miss': coverage_rows[module].miss, + 'cover_pct': coverage_rows[module].cover_pct, + } + for module in TIER1_COVERAGE_MODULES + if module in coverage_rows + }, + 'mutation_raw': mutation_raw, + } + + if args.json: + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + print(f"Full-suite line coverage: {result['full_suite_coverage_pct']}%") + print(f"Tier 1 weighted coverage: {result['tier1_weighted_coverage_pct']}%") + print( + 'Tier 1 scenario coverage: ' + f"{result['tier1_scenario_covered']}/{result['tier1_scenario_total']} " + f"({result['tier1_scenario_strict_pct']}%)" + ) + print(f"Tier 1 scenario partial-weighted: {result['tier1_scenario_partial_weighted_pct']}%") + mutation_value = 'n/a' if result['tier1_mutation_score_pct'] is None else f"{result['tier1_mutation_score_pct']}%" + print(f'Tier 1 mutation score: {mutation_value}') + if result['tier1_regression_protection_pct'] is None: + print('Tier 1 regression protection index: n/a') + else: + print( + 'Tier 1 regression protection index: ' + f"{result['tier1_regression_protection_pct']}% " + f"({result['tier1_regression_with_test']}/{result['tier1_regression_total']})" + ) + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/src/locale/de/LC_MESSAGES/django.po b/src/locale/de/LC_MESSAGES/django.po index 03ac8c4..4134de8 100644 --- a/src/locale/de/LC_MESSAGES/django.po +++ b/src/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-09-25 15:12+0000\n" +"POT-Creation-Date: 2026-04-17 05:17+0200\n" "PO-Revision-Date: 2023-04-17 23:37+0200\n" "Last-Translator: \n" "Language-Team: \n" @@ -18,1689 +18,98 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 3.1.1\n" -#: venv/lib/python3.11/site-packages/click/_termui_impl.py:518 -#, python-brace-format -msgid "{editor}: Editing failed" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/_termui_impl.py:522 -#, python-brace-format -msgid "{editor}: Editing failed: {e}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1120 -#, fuzzy -#| msgid "Abort" -msgid "Aborted!" -msgstr "Abbrechen" - -#: venv/lib/python3.11/site-packages/click/core.py:1309 -#: venv/lib/python3.11/site-packages/click/decorators.py:559 -msgid "Show this message and exit." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1340 -#: venv/lib/python3.11/site-packages/click/core.py:1370 -#, python-brace-format -msgid "(Deprecated) {text}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1387 -#, fuzzy -#| msgid "Actions" -msgid "Options" -msgstr "Aktionen" - -#: venv/lib/python3.11/site-packages/click/core.py:1413 -#, python-brace-format -msgid "Got unexpected extra argument ({args})" -msgid_plural "Got unexpected extra arguments ({args})" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/core.py:1429 -msgid "DeprecationWarning: The command {name!r} is deprecated." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1636 -msgid "Commands" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1668 -msgid "Missing command." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1746 -msgid "No such command {name!r}." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2310 -msgid "Value must be an iterable." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2331 -#, python-brace-format -msgid "Takes {nargs} values but 1 was given." -msgid_plural "Takes {nargs} values but {len} were given." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/core.py:2778 -#, python-brace-format -msgid "env var: {var}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2808 -msgid "(dynamic)" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2821 -#, python-brace-format -msgid "default: {default}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2834 -#, fuzzy -#| msgid "Required" -msgid "required" -msgstr "Benötigt" - -#: venv/lib/python3.11/site-packages/click/decorators.py:465 -#, python-format -msgid "%(prog)s, version %(version)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/decorators.py:528 -msgid "Show the version and exit." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:44 -#: venv/lib/python3.11/site-packages/click/exceptions.py:80 -#, python-brace-format -msgid "Error: {message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:72 -#, python-brace-format -msgid "Try '{command} {option}' for help." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:121 -#, python-brace-format -msgid "Invalid value: {message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:123 -#, python-brace-format -msgid "Invalid value for {param_hint}: {message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:179 -msgid "Missing argument" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:181 -msgid "Missing option" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:183 -msgid "Missing parameter" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:185 -#, python-brace-format -msgid "Missing {param_type}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:192 -#, python-brace-format -msgid "Missing parameter: {param_name}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:212 -#, python-brace-format -msgid "No such option: {name}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:224 -#, python-brace-format -msgid "Did you mean {possibility}?" -msgid_plural "(Possible options: {possibilities})" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:262 -#, fuzzy -#| msgid "Unknown User" -msgid "unknown error" -msgstr "Unbekannter Benutzer" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:269 -msgid "Could not open file {filename!r}: {message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/parser.py:231 -msgid "Argument {name!r} takes {nargs} values." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/parser.py:413 -msgid "Option {name!r} does not take a value." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/parser.py:474 -msgid "Option {name!r} requires an argument." -msgid_plural "Option {name!r} requires {nargs} arguments." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/shell_completion.py:319 -msgid "Shell completion is not supported for Bash versions older than 4.4." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/shell_completion.py:326 -msgid "Couldn't detect Bash version, shell completion is not supported." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:158 -msgid "Repeat for confirmation" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:174 -msgid "Error: The value you entered was invalid." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:176 -#, python-brace-format -msgid "Error: {e.message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:187 -msgid "Error: The two entered values do not match." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:243 -msgid "Error: invalid input" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:773 -msgid "Press any key to continue..." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:266 -#, python-brace-format -msgid "" -"Choose from:\n" -"\t{choices}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:298 -msgid "{value!r} is not {choice}." -msgid_plural "{value!r} is not one of {choices}." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/types.py:392 -msgid "{value!r} does not match the format {format}." -msgid_plural "{value!r} does not match the formats {formats}." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/types.py:414 -msgid "{value!r} is not a valid {number_type}." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:470 -#, python-brace-format -msgid "{value} is not in the range {range}." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:611 -msgid "{value!r} is not a valid boolean." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:635 -msgid "{value!r} is not a valid UUID." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:822 -#, fuzzy -#| msgid "Profile" -msgid "file" -msgstr "Profil" - -#: venv/lib/python3.11/site-packages/click/types.py:824 -msgid "directory" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:826 -msgid "path" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:877 -msgid "{name} {filename!r} does not exist." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:886 -msgid "{name} {filename!r} is a file." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:894 -#, python-brace-format -msgid "{name} '{filename}' is a directory." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:903 -msgid "{name} {filename!r} is not readable." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:912 -msgid "{name} {filename!r} is not writable." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:921 -msgid "{name} {filename!r} is not executable." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:988 -#, python-brace-format -msgid "{len_type} values are required, but {len_value} was given." -msgid_plural "{len_type} values are required, but {len_value} were given." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/colorfield/validators.py:9 -msgid "Enter a valid hex color, eg. #000000" -msgstr "" - -#: venv/lib/python3.11/site-packages/colorfield/validators.py:17 -msgid "Enter a valid hexa color, eg. #00000000" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/contrib/messages/apps.py:7 -msgid "Messages" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/contrib/sitemaps/apps.py:8 -msgid "Site Maps" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/contrib/staticfiles/apps.py:9 -msgid "Static Files" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/contrib/syndication/apps.py:7 -#, fuzzy -#| msgid "Organization" -msgid "Syndication" -msgstr "Organisation" - -#. Translators: String used to replace omitted page numbers in elided page -#. range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10]. -#: venv/lib/python3.11/site-packages/django/core/paginator.py:30 -msgid "…" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/paginator.py:50 -msgid "That page number is not an integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/paginator.py:52 -msgid "That page number is less than 1" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/paginator.py:57 -msgid "That page contains no results" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:23 -msgid "Enter a valid value." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:105 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:743 -msgid "Enter a valid URL." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:166 -msgid "Enter a valid integer." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:177 -msgid "Enter a valid email address." -msgstr "" - -#. Translators: "letters" means latin letters: a-z and A-Z. -#: venv/lib/python3.11/site-packages/django/core/validators.py:285 -msgid "" -"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:293 -msgid "" -"Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or " -"hyphens." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:305 -#: venv/lib/python3.11/site-packages/django/core/validators.py:313 -#: venv/lib/python3.11/site-packages/django/core/validators.py:342 -msgid "Enter a valid IPv4 address." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:322 -#: venv/lib/python3.11/site-packages/django/core/validators.py:343 -msgid "Enter a valid IPv6 address." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:334 -#: venv/lib/python3.11/site-packages/django/core/validators.py:341 -msgid "Enter a valid IPv4 or IPv6 address." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:377 -msgid "Enter only digits separated by commas." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:383 -#, python-format -msgid "Ensure this value is %(limit_value)s (it is %(show_value)s)." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:418 -#, python-format -msgid "Ensure this value is less than or equal to %(limit_value)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:427 -#, python-format -msgid "Ensure this value is greater than or equal to %(limit_value)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:437 -#, python-format -msgid "" -"Ensure this value has at least %(limit_value)d character (it has " -"%(show_value)d)." -msgid_plural "" -"Ensure this value has at least %(limit_value)d characters (it has " -"%(show_value)d)." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:455 -#, python-format -msgid "" -"Ensure this value has at most %(limit_value)d character (it has " -"%(show_value)d)." -msgid_plural "" -"Ensure this value has at most %(limit_value)d characters (it has " -"%(show_value)d)." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:478 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:343 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:378 -#, fuzzy -#| msgid "Enter Name" -msgid "Enter a number." -msgstr "Name einegeben" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:480 -#, python-format -msgid "Ensure that there are no more than %(max)s digit in total." -msgid_plural "Ensure that there are no more than %(max)s digits in total." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:485 -#, python-format -msgid "Ensure that there are no more than %(max)s decimal place." -msgid_plural "Ensure that there are no more than %(max)s decimal places." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:490 -#, python-format -msgid "" -"Ensure that there are no more than %(max)s digit before the decimal point." -msgid_plural "" -"Ensure that there are no more than %(max)s digits before the decimal point." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:559 -#, python-format -msgid "" -"File extension “%(extension)s” is not allowed. Allowed extensions are: " -"%(allowed_extensions)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:620 -msgid "Null characters are not allowed." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/base.py:1364 -#: venv/lib/python3.11/site-packages/django/forms/models.py:894 -#, fuzzy -#| msgid "End" -msgid "and" -msgstr "Ende" - -#: venv/lib/python3.11/site-packages/django/db/models/base.py:1366 -#, python-format -msgid "%(model_name)s with this %(field_labels)s already exists." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:129 -#, python-format -msgid "Value %(value)r is not a valid choice." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:130 -msgid "This field cannot be null." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:131 -msgid "This field cannot be blank." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:132 -#, python-format -msgid "%(model_name)s with this %(field_label)s already exists." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:136 -#, python-format -msgid "" -"%(field_label)s must be unique for %(date_field_label)s %(lookup_type)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:156 -#, python-format -msgid "Field of type: %(field_type)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1050 -#, python-format -msgid "“%(value)s” value must be either True or False." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1051 -#, python-format -msgid "“%(value)s” value must be either True, False, or None." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1053 -msgid "Boolean (Either True or False)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1094 -#, python-format -msgid "String (up to %(max_length)s)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1192 -msgid "Comma-separated integers" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1293 -#, python-format -msgid "" -"“%(value)s” value has an invalid date format. It must be in YYYY-MM-DD " -"format." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1297 -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1432 -#, python-format -msgid "" -"“%(value)s” value has the correct format (YYYY-MM-DD) but it is an invalid " -"date." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1301 -msgid "Date (without time)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1428 -#, python-format -msgid "" -"“%(value)s” value has an invalid format. It must be in YYYY-MM-DD HH:MM[:ss[." -"uuuuuu]][TZ] format." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1436 -#, python-format -msgid "" -"“%(value)s” value has the correct format (YYYY-MM-DD HH:MM[:ss[.uuuuuu]]" -"[TZ]) but it is an invalid date/time." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1441 -msgid "Date (with time)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1565 -#, python-format -msgid "“%(value)s” value must be a decimal number." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1567 -#, fuzzy -#| msgid "December" -msgid "Decimal number" -msgstr "Dezember" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1724 -#, python-format -msgid "" -"“%(value)s” value has an invalid format. It must be in [DD] [[HH:]MM:]ss[." -"uuuuuu] format." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1728 -#: src/shiftings/shifts/forms/template.py:40 -#: src/shiftings/shifts/models/template.py:23 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:11 -msgid "Duration" -msgstr "Dauer" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1780 -msgid "Email address" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1805 -msgid "File path" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1883 -#, python-format -msgid "“%(value)s” value must be a float." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1885 -msgid "Floating point number" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1925 -#, python-format -msgid "“%(value)s” value must be an integer." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1927 -msgid "Integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2019 -msgid "Big (8 byte) integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2036 -msgid "Small integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2044 -msgid "IPv4 address" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2075 -msgid "IP address" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2168 -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2169 -#, python-format -msgid "“%(value)s” value must be either None, True or False." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2171 -msgid "Boolean (Either True, False or None)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2222 -msgid "Positive big integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2237 -msgid "Positive integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2252 -msgid "Positive small integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2268 -#, python-format -msgid "Slug (up to %(max_length)s)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2304 -#: src/shiftings/mail/forms/mail.py:11 -msgid "Text" -msgstr "Text" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2374 -#, python-format -msgid "" -"“%(value)s” value has an invalid format. It must be in HH:MM[:ss[.uuuuuu]] " -"format." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2378 -#, python-format -msgid "" -"“%(value)s” value has the correct format (HH:MM[:ss[.uuuuuu]]) but it is an " -"invalid time." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2382 -#: src/shiftings/cal/templates/cal/template/day_calendar_shift_types.html:23 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:197 -#: src/shiftings/shifts/templates/shifts/template/group.html:43 -#: src/shiftings/shifts/templates/shifts/template/group_template.html:23 -#: src/shiftings/shifts/templates/shifts/template/shift.html:10 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:33 -msgid "Time" -msgstr "Zeit" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2490 -msgid "URL" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2514 -msgid "Raw binary data" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2579 -#, python-format -msgid "“%(value)s” is not a valid UUID." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2581 -msgid "Universally unique identifier" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/files.py:232 -msgid "File" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/files.py:392 -msgid "Image" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/json.py:18 -#, fuzzy -#| msgid "Subject" -msgid "A JSON object" -msgstr "Betreff" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/json.py:20 -msgid "Value must be valid JSON." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:903 -#, python-format -msgid "%(model)s instance with %(field)s %(value)r does not exist." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:905 -msgid "Foreign Key (type determined by related field)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:1204 -msgid "One-to-one relationship" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:1261 -#, python-format -msgid "%(from)s-%(to)s relationship" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:1263 -#, python-format -msgid "%(from)s-%(to)s relationships" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:1311 -msgid "Many-to-many relationship" -msgstr "" - -#. Translators: If found as last label character, these punctuation -#. characters will prevent the default label_suffix to be appended to the label -#: venv/lib/python3.11/site-packages/django/forms/boundfield.py:176 -msgid ":?.!" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:91 -#, fuzzy -#| msgid "One of the user fields is required!" -msgid "This field is required." -msgstr "Eines der Benutzerfelder ist benötigt!" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:298 -msgid "Enter a whole number." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:459 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1232 -msgid "Enter a valid date." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:482 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1233 -#, fuzzy -#| msgid "Enter Name" -msgid "Enter a valid time." -msgstr "Name einegeben" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:509 -msgid "Enter a valid date/time." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:543 -#, fuzzy -#| msgid "Enter Name or Organization" -msgid "Enter a valid duration." -msgstr "Name oder Organisation angeben" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:544 -#, python-brace-format -msgid "The number of days must be between {min_days} and {max_days}." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:610 -msgid "No file was submitted. Check the encoding type on the form." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:611 -msgid "No file was submitted." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:612 -msgid "The submitted file is empty." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:614 -#, python-format -msgid "Ensure this filename has at most %(max)d character (it has %(length)d)." -msgid_plural "" -"Ensure this filename has at most %(max)d characters (it has %(length)d)." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:619 -msgid "Please either submit a file or check the clear checkbox, not both." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:685 -msgid "" -"Upload a valid image. The file you uploaded was either not an image or a " -"corrupted image." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:848 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:940 -#: venv/lib/python3.11/site-packages/django/forms/models.py:1563 -#, python-format -msgid "Select a valid choice. %(value)s is not one of the available choices." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:942 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1061 -#: venv/lib/python3.11/site-packages/django/forms/models.py:1561 -msgid "Enter a list of values." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1062 -msgid "Enter a complete value." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1301 -msgid "Enter a valid UUID." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1331 -msgid "Enter a valid JSON." -msgstr "" - -#. Translators: This is the default suffix added to form field labels -#: venv/lib/python3.11/site-packages/django/forms/forms.py:98 -msgid ":" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/forms.py:248 -#: venv/lib/python3.11/site-packages/django/forms/forms.py:328 -#, python-format -msgid "(Hidden field %(name)s) %(error)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:67 -#, python-format -msgid "" -"ManagementForm data is missing or has been tampered with. Missing fields: " -"%(field_names)s. You may need to file a bug report if the issue persists." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:418 -#, python-format -msgid "Please submit at most %d form." -msgid_plural "Please submit at most %d forms." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:434 -#, python-format -msgid "Please submit at least %d form." -msgid_plural "Please submit at least %d forms." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:470 -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:477 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:38 -msgid "Order" -msgstr "Reihenfolge" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:483 -#, fuzzy -#| msgid "Delete Shift" -msgid "Delete" -msgstr "Schicht löschen" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:887 -#, python-format -msgid "Please correct the duplicate data for %(field)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:892 -#, python-format -msgid "Please correct the duplicate data for %(field)s, which must be unique." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:899 -#, python-format -msgid "" -"Please correct the duplicate data for %(field_name)s which must be unique " -"for the %(lookup)s in %(date_field)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:908 -msgid "Please correct the duplicate values below." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:1335 -msgid "The inline value did not match the parent instance." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:1426 -msgid "Select a valid choice. That choice is not one of the available choices." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:1565 -#, python-format -msgid "“%(pk)s” is not a valid value." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/utils.py:204 -#, python-format -msgid "" -"%(datetime)s couldn’t be interpreted in time zone %(current_timezone)s; it " -"may be ambiguous or it may not exist." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:434 -msgid "Clear" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:435 -#, fuzzy -#| msgid "Current Events" -msgid "Currently" -msgstr "Aktuelle Events" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:436 -msgid "Change" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:765 -#: src/shiftings/accounts/templates/accounts/user_detail.html:55 -#: src/shiftings/accounts/templates/accounts/user_detail.html:65 -msgid "Unknown" -msgstr "Unbekannt" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:766 -#: src/shiftings/templates/generic/delete.html:9 -msgid "Yes" -msgstr "Ja" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:767 -#: src/shiftings/templates/generic/delete.html:11 -msgid "No" -msgstr "Nein" - -#. Translators: Please do not add spaces around commas. -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:858 -msgid "yes,no,maybe" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:888 -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:905 -#, python-format -msgid "%(size)d byte" -msgid_plural "%(size)d bytes" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:907 -#, python-format -msgid "%s KB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:909 -#, python-format -msgid "%s MB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:911 -#, python-format -msgid "%s GB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:913 -#, python-format -msgid "%s TB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:915 -#, python-format -msgid "%s PB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:77 -msgid "p.m." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:78 -msgid "a.m." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:83 -#, fuzzy -#| msgid "M" -msgid "PM" -msgstr "M" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:84 -#, fuzzy -#| msgid "M" -msgid "AM" -msgstr "M" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:155 -msgid "midnight" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:157 -msgid "noon" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:7 -#: src/shiftings/utils/time/week.py:13 -msgid "Monday" -msgstr "Montag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:8 -#: src/shiftings/utils/time/week.py:14 -msgid "Tuesday" -msgstr "Dienstag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:9 -#: src/shiftings/utils/time/week.py:15 -msgid "Wednesday" -msgstr "Mittwoch" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:10 -#: src/shiftings/utils/time/week.py:16 -msgid "Thursday" -msgstr "Donnerstag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:11 -#: src/shiftings/utils/time/week.py:17 -msgid "Friday" -msgstr "Freitag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:12 -#: src/shiftings/utils/time/week.py:18 -msgid "Saturday" -msgstr "Samstag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:13 -#: src/shiftings/utils/time/week.py:19 -msgid "Sunday" -msgstr "Sonntag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:16 -#, fuzzy -#| msgid "Month" -msgid "Mon" -msgstr "Monat" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:17 -#, fuzzy -#| msgid "Tuesday" -msgid "Tue" -msgstr "Dienstag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:18 -msgid "Wed" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:19 -msgid "Thu" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:20 -#, fuzzy -#| msgid "Friday" -msgid "Fri" -msgstr "Freitag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:21 -#, fuzzy -#| msgid "Start" -msgid "Sat" -msgstr "Start" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:22 -#, fuzzy -#| msgid "Sunday" -msgid "Sun" -msgstr "Sonntag" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:25 -#: src/shiftings/utils/time/month.py:10 -msgid "January" -msgstr "Januar" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:26 -#: src/shiftings/utils/time/month.py:11 -msgid "February" -msgstr "Februar" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:27 -#: src/shiftings/utils/time/month.py:12 -msgid "March" -msgstr "März" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:28 -#: src/shiftings/utils/time/month.py:13 -msgid "April" -msgstr "April" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:29 -#: src/shiftings/utils/time/month.py:14 -msgid "May" -msgstr "Mai" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:30 -#: src/shiftings/utils/time/month.py:15 -msgid "June" -msgstr "Juni" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:31 -#: src/shiftings/utils/time/month.py:16 -msgid "July" -msgstr "Juli" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:32 -#: src/shiftings/utils/time/month.py:17 -msgid "August" -msgstr "August" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:33 -#: src/shiftings/utils/time/month.py:18 -msgid "September" -msgstr "September" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:34 -#: src/shiftings/utils/time/month.py:19 -msgid "October" -msgstr "Oktober" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:35 -#: src/shiftings/utils/time/month.py:20 -msgid "November" -msgstr "November" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:36 -#: src/shiftings/utils/time/month.py:21 -msgid "December" -msgstr "Dezember" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:39 -msgid "jan" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:40 -msgid "feb" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:41 -msgid "mar" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:42 -msgid "apr" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:43 -#, fuzzy -#| msgid "May" -msgid "may" -msgstr "Mai" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:44 -msgid "jun" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:45 -msgid "jul" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:46 -msgid "aug" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:47 -msgid "sep" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:48 -msgid "oct" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:49 -msgid "nov" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:50 -msgid "dec" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:53 -msgctxt "abbrev. month" -msgid "Jan." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:54 -msgctxt "abbrev. month" -msgid "Feb." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:55 -#, fuzzy -#| msgid "March" -msgctxt "abbrev. month" -msgid "March" -msgstr "März" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:56 -#, fuzzy -#| msgid "April" -msgctxt "abbrev. month" -msgid "April" -msgstr "April" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:57 -#, fuzzy -#| msgid "May" -msgctxt "abbrev. month" -msgid "May" -msgstr "Mai" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:58 -#, fuzzy -#| msgid "June" -msgctxt "abbrev. month" -msgid "June" -msgstr "Juni" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:59 -#, fuzzy -#| msgid "July" -msgctxt "abbrev. month" -msgid "July" -msgstr "Juli" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:60 -msgctxt "abbrev. month" -msgid "Aug." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:61 -msgctxt "abbrev. month" -msgid "Sept." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:62 -msgctxt "abbrev. month" -msgid "Oct." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:63 -msgctxt "abbrev. month" -msgid "Nov." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:64 -msgctxt "abbrev. month" -msgid "Dec." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:67 -#, fuzzy -#| msgid "January" -msgctxt "alt. month" -msgid "January" -msgstr "Januar" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:68 -#, fuzzy -#| msgid "February" -msgctxt "alt. month" -msgid "February" -msgstr "Februar" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:69 -#, fuzzy -#| msgid "March" -msgctxt "alt. month" -msgid "March" -msgstr "März" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:70 -#, fuzzy -#| msgid "April" -msgctxt "alt. month" -msgid "April" -msgstr "April" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:71 -#, fuzzy -#| msgid "May" -msgctxt "alt. month" -msgid "May" -msgstr "Mai" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:72 -#, fuzzy -#| msgid "June" -msgctxt "alt. month" -msgid "June" -msgstr "Juni" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:73 -#, fuzzy -#| msgid "July" -msgctxt "alt. month" -msgid "July" -msgstr "Juli" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:74 -#, fuzzy -#| msgid "August" -msgctxt "alt. month" -msgid "August" -msgstr "August" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:75 -#, fuzzy -#| msgid "September" -msgctxt "alt. month" -msgid "September" -msgstr "September" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:76 -#, fuzzy -#| msgid "October" -msgctxt "alt. month" -msgid "October" -msgstr "Oktober" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:77 -#, fuzzy -#| msgid "November" -msgctxt "alt. month" -msgid "November" -msgstr "November" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:78 -#, fuzzy -#| msgid "December" -msgctxt "alt. month" -msgid "December" -msgstr "Dezember" - -#: venv/lib/python3.11/site-packages/django/utils/ipv6.py:8 -msgid "This is not a valid IPv6 address." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/text.py:77 -#, python-format -msgctxt "String to return when truncating text" -msgid "%(truncated_text)s…" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/text.py:253 -msgid "or" -msgstr "" - -#. Translators: This string is used as a separator between list elements -#: venv/lib/python3.11/site-packages/django/utils/text.py:272 -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:94 -msgid ", " -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:9 -#, python-format -msgid "%(num)d year" -msgid_plural "%(num)d years" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:10 -#, python-format -msgid "%(num)d month" -msgid_plural "%(num)d months" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:11 -#, python-format -msgid "%(num)d week" -msgid_plural "%(num)d weeks" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:12 -#, python-format -msgid "%(num)d day" -msgid_plural "%(num)d days" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:13 -#, python-format -msgid "%(num)d hour" -msgid_plural "%(num)d hours" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:14 -#, python-format -msgid "%(num)d minute" -msgid_plural "%(num)d minutes" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:111 -#, fuzzy -#| msgid "Forbidden Method GET" -msgid "Forbidden" -msgstr "Verbotene Methode GET" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:112 -msgid "CSRF verification failed. Request aborted." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:116 -msgid "" -"You are seeing this message because this HTTPS site requires a “Referer " -"header” to be sent by your web browser, but none was sent. This header is " -"required for security reasons, to ensure that your browser is not being " -"hijacked by third parties." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:122 -msgid "" -"If you have configured your browser to disable “Referer” headers, please re-" -"enable them, at least for this site, or for HTTPS connections, or for “same-" -"origin” requests." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:127 -msgid "" -"If you are using the tag or " -"including the “Referrer-Policy: no-referrer” header, please remove them. The " -"CSRF protection requires the “Referer” header to do strict referer checking. " -"If you’re concerned about privacy, use alternatives like for links to third-party sites." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:136 -msgid "" -"You are seeing this message because this site requires a CSRF cookie when " -"submitting forms. This cookie is required for security reasons, to ensure " -"that your browser is not being hijacked by third parties." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:142 -msgid "" -"If you have configured your browser to disable cookies, please re-enable " -"them, at least for this site, or for “same-origin” requests." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:148 -msgid "More information is available with DEBUG=True." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:44 -msgid "No year specified" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:64 -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:115 -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:214 -msgid "Date out of range" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:94 -msgid "No month specified" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:147 -msgid "No day specified" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:194 -msgid "No week specified" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:349 -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:380 -#, python-format -msgid "No %(verbose_name_plural)s available" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:652 -#, python-format -msgid "" -"Future %(verbose_name_plural)s not available because %(class_name)s." -"allow_future is False." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:692 -#, python-format -msgid "Invalid date string “%(datestr)s” given format “%(format)s”" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/detail.py:56 -#, python-format -msgid "No %(verbose_name)s found matching the query" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/list.py:70 -msgid "Page is not “last”, nor can it be converted to an int." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/list.py:77 -#, python-format -msgid "Invalid page (%(page_number)s): %(message)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/list.py:169 -#, python-format -msgid "Empty list and “%(class_name)s.allow_empty” is False." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/static.py:39 -msgid "Directory indexes are not allowed here." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/static.py:41 -#, python-format -msgid "“%(path)s” does not exist" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/static.py:80 -#, python-format -msgid "Index of %(directory)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:7 -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:221 -msgid "The install worked successfully! Congratulations!" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:207 -#, python-format -msgid "" -"View release notes for Django %(version)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:222 -#, python-format -msgid "" -"You are seeing this page because DEBUG=True is in your settings file and you have not " -"configured any URLs." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:230 -msgid "Django Documentation" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:231 -msgid "Topics, references, & how-to’s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:239 -msgid "Tutorial: A Polling App" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:240 -msgid "Get started with Django" -msgstr "" +#: shiftings/accounts/forms/user_form.py:13 +msgid "Confirm password" +msgstr "Passwort bestätigen" -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:248 -msgid "Django Community" -msgstr "" +#: shiftings/accounts/forms/user_form.py:31 +#: shiftings/accounts/forms/user_form.py:32 +msgid "Please enter matching passwords" +msgstr "Bitte gebe identische Passwörter ein" -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:249 -msgid "Connect, get help, or contribute" +#: shiftings/accounts/forms/user_form.py:65 +msgid "Cannot change first name, last name or email for LDAP users." msgstr "" -#: venv/lib/python3.11/site-packages/django_bootstrap5/components.py:26 -#, fuzzy -#| msgid "Close" -msgid "close" -msgstr "Schließen" +#: shiftings/accounts/models/user.py:33 shiftings/templates/theme.html:28 +msgid "Light" +msgstr "Hell" -#: venv/lib/python3.11/site-packages/isort/main.py:158 -msgid "show this help message and exit" -msgstr "" +#: shiftings/accounts/models/user.py:34 shiftings/templates/theme.html:31 +msgid "Dark" +msgstr "Dunkel" -#: venv/lib/python3.11/site-packages/mypy/main.py:376 -#, python-format -msgid "%(prog)s: error: %(message)s\n" +#: shiftings/accounts/models/user.py:35 shiftings/templates/theme.html:34 +msgid "Auto" msgstr "" -#: src/shiftings/accounts/forms/user_form.py:11 -msgid "Confirm password" -msgstr "Passwort bestätigen" - -#: src/shiftings/accounts/forms/user_form.py:30 -msgid "Please enter matching passwords" -msgstr "Bitte gebe identische Passwörter ein" - -#: src/shiftings/accounts/models/user.py:32 -#: src/shiftings/accounts/templates/accounts/user_detail.html:52 -#: src/shiftings/shifts/models/participant.py:15 +#: shiftings/accounts/models/user.py:37 +#: shiftings/accounts/templates/accounts/user_detail.html:52 +#: shiftings/shifts/models/participant.py:15 msgid "Display Name" msgstr "Anzeigename" -#: src/shiftings/accounts/models/user.py:33 -#: src/shiftings/events/models/event.py:29 -#: src/shiftings/organizations/models/organization.py:28 +#: shiftings/accounts/models/user.py:38 shiftings/events/models/event.py:29 +#: shiftings/organizations/models/organization.py:28 msgid "Telephone Number" msgstr "Telefonnummer" -#: src/shiftings/accounts/templates/accounts/confirm_email.html:7 +#: shiftings/accounts/models/user.py:42 +msgid "Theme Preference" +msgstr "" + +#: shiftings/accounts/templates/accounts/confirm_email.html:7 msgid "Go to Login" msgstr "Zum Login" -#: src/shiftings/accounts/templates/accounts/login_form.html:6 -#: src/shiftings/accounts/templates/accounts/login_multiple.html:12 +#: shiftings/accounts/templates/accounts/login_form.html:6 +#: shiftings/accounts/templates/accounts/login_multiple.html:12 msgid "Login with local Account" msgstr "Login mit lokalem Account" -#: src/shiftings/accounts/templates/accounts/login_form.html:8 -#: src/shiftings/accounts/templates/accounts/login_multiple.html:20 +#: shiftings/accounts/templates/accounts/login_form.html:8 +#: shiftings/accounts/templates/accounts/login_multiple.html:20 msgid "Login with LDAP Account" msgstr "Login mit LDAP Account" -#: src/shiftings/accounts/templates/accounts/login_form.html:14 -#: src/shiftings/accounts/views/auth.py:27 -#: src/shiftings/templates/top_nav.html:57 +#: shiftings/accounts/templates/accounts/login_form.html:14 +#: shiftings/accounts/views/auth.py:26 shiftings/templates/top_nav.html:61 msgid "Login" msgstr "Einloggen" -#: src/shiftings/accounts/templates/accounts/login_form.html:16 -#: src/shiftings/accounts/templates/accounts/password_reset/prompt.html:10 +#: shiftings/accounts/templates/accounts/login_form.html:16 +#: shiftings/accounts/templates/accounts/password_reset/prompt.html:10 msgid "Reset Password" msgstr "Passwort zurücksetzen" -#: src/shiftings/accounts/templates/accounts/login_form.html:18 +#: shiftings/accounts/templates/accounts/login_form.html:18 msgid "Return to selection" msgstr "Zur Auswahl zurückkehren" -#: src/shiftings/accounts/templates/accounts/login_form.html:29 +#: shiftings/accounts/templates/accounts/login_form.html:29 msgid "You don't have an Account?" msgstr "Noch kein Account?" -#: src/shiftings/accounts/templates/accounts/login_form.html:30 +#: shiftings/accounts/templates/accounts/login_form.html:30 msgid "Register here" msgstr "Hier registrieren" -#: src/shiftings/accounts/templates/accounts/login_form.html:32 +#: shiftings/accounts/templates/accounts/login_form.html:32 msgid "Or login using another Method" msgstr "Oder nutze eine andere Loginmethode" -#: src/shiftings/accounts/templates/accounts/login_multiple.html:5 +#: shiftings/accounts/templates/accounts/login_multiple.html:5 msgid "Welcome to Shiftings" msgstr "Wilkommen bei Shiftings" -#: src/shiftings/accounts/templates/accounts/login_multiple.html:6 +#: shiftings/accounts/templates/accounts/login_multiple.html:6 msgid "" "To manage your shifts please authenticate with one of the given methods:" msgstr "" "Um deine Schichten zu verwalten melde dich bitte mit einer dieser Methoden " "an:" -#: src/shiftings/accounts/templates/accounts/login_multiple.html:31 +#: shiftings/accounts/templates/accounts/login_multiple.html:31 #, fuzzy, python-format #| msgid "" #| "\n" @@ -1715,28 +124,28 @@ msgstr "" " vor %(time)s\n" " " -#: src/shiftings/accounts/templates/accounts/logout.html:6 +#: shiftings/accounts/templates/accounts/logout.html:6 msgid "Successfully logged out" msgstr "Erfolgreich ausgeloggt" -#: src/shiftings/accounts/templates/accounts/logout.html:7 +#: shiftings/accounts/templates/accounts/logout.html:7 msgid "Login again" msgstr "Wieder einloggen" -#: src/shiftings/accounts/templates/accounts/password_reset/confirm.html:7 +#: shiftings/accounts/templates/accounts/password_reset/confirm.html:7 msgid "Change Password" msgstr "Passwort ändern" -#: src/shiftings/accounts/templates/accounts/password_reset/done.html:4 +#: shiftings/accounts/templates/accounts/password_reset/done.html:4 msgid "Your password has been reset, check your email!" msgstr "Dein Passwort wurde zurückgesetzt, überprüfe deine E-Mails!" -#: src/shiftings/accounts/templates/accounts/password_reset/done.html:5 -#: src/shiftings/accounts/templates/accounts/password_reset/success.html:5 +#: shiftings/accounts/templates/accounts/password_reset/done.html:5 +#: shiftings/accounts/templates/accounts/password_reset/success.html:5 msgid "Back to login" msgstr "Zurück zum Login" -#: src/shiftings/accounts/templates/accounts/password_reset/prompt.html:4 +#: shiftings/accounts/templates/accounts/password_reset/prompt.html:4 msgid "" "\n" " If you are an local user enter your email-adress below to reset your " @@ -1748,15 +157,15 @@ msgstr "" "Passwort zurückzusetzen!\n" " " -#: src/shiftings/accounts/templates/accounts/password_reset/success.html:4 +#: shiftings/accounts/templates/accounts/password_reset/success.html:4 msgid "Your password has been successfully reset!" msgstr "Deine Passwort wurde erfolgreich zurückgesetzt!" -#: src/shiftings/accounts/templates/accounts/user_detail.html:3 +#: shiftings/accounts/templates/accounts/user_detail.html:3 msgid "Confirm User Deletion" msgstr "Bestätige die Löschung des Benutzers" -#: src/shiftings/accounts/templates/accounts/user_detail.html:5 +#: shiftings/accounts/templates/accounts/user_detail.html:5 #, python-format msgid "" "\n" @@ -1772,128 +181,131 @@ msgstr "" "werden.\n" " " -#: src/shiftings/accounts/templates/accounts/user_detail.html:17 +#: shiftings/accounts/templates/accounts/user_detail.html:17 msgid "Yes, I want to permanently delete my Data" msgstr "Ja, ich möchte meine Daten entgültig löschen" -#: src/shiftings/accounts/templates/accounts/user_detail.html:19 +#: shiftings/accounts/templates/accounts/user_detail.html:19 msgid "Abort" msgstr "Abbrechen" -#: src/shiftings/accounts/templates/accounts/user_detail.html:29 -#: src/shiftings/organizations/models/membership.py:68 -#: src/shiftings/shifts/models/participant.py:13 -#: src/shiftings/templates/top_nav.html:47 +#: shiftings/accounts/templates/accounts/user_detail.html:29 +#: shiftings/organizations/models/membership.py:68 +#: shiftings/shifts/models/participant.py:13 +#: shiftings/templates/top_nav.html:48 msgid "User" msgstr "Benutzer" -#: src/shiftings/accounts/templates/accounts/user_detail.html:37 -#: src/shiftings/accounts/templates/accounts/user_detail.html:57 +#: shiftings/accounts/templates/accounts/user_detail.html:37 +#: shiftings/accounts/templates/accounts/user_detail.html:57 msgid "Edit user data" msgstr "Benutzerdaten bearbeiten" -#: src/shiftings/accounts/templates/accounts/user_detail.html:48 -#: src/shiftings/organizations/forms/membership.py:16 +#: shiftings/accounts/templates/accounts/user_detail.html:48 +#: shiftings/organizations/forms/membership.py:16 msgid "Username" msgstr "Benutzername" -#: src/shiftings/accounts/templates/accounts/user_detail.html:50 -#: src/shiftings/events/models/event.py:25 -#: src/shiftings/events/templates/events/event.html:85 -#: src/shiftings/organizations/models/membership.py:10 -#: src/shiftings/organizations/models/organization.py:24 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:195 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:239 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:278 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:388 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:39 -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:13 -#: src/shiftings/shifts/models/base.py:8 -#: src/shiftings/shifts/models/recurring.py:32 -#: src/shiftings/shifts/models/template.py:17 -#: src/shiftings/shifts/models/template_group.py:20 -#: src/shiftings/shifts/models/type.py:40 -#: src/shiftings/shifts/models/type_group.py:16 -#: src/shiftings/shifts/templates/shifts/group/list.html:6 -#: src/shiftings/shifts/templates/shifts/list.html:6 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:6 -#: src/shiftings/shifts/templates/shifts/template/group_list.html:6 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:5 -#: src/shiftings/shifts/templates/shifts/type_group/list.html:7 +#: shiftings/accounts/templates/accounts/user_detail.html:50 +#: shiftings/events/models/event.py:25 +#: shiftings/events/templates/events/event.html:85 +#: shiftings/organizations/models/membership.py:10 +#: shiftings/organizations/models/organization.py:24 +#: shiftings/organizations/templates/organizations/organization_admin.html:195 +#: shiftings/organizations/templates/organizations/organization_admin.html:239 +#: shiftings/organizations/templates/organizations/organization_admin.html:278 +#: shiftings/organizations/templates/organizations/organization_admin.html:388 +#: shiftings/organizations/templates/organizations/organization_settings.html:39 +#: shiftings/organizations/templates/organizations/user/claim_users.html:13 +#: shiftings/shifts/models/base.py:8 shiftings/shifts/models/recurring.py:32 +#: shiftings/shifts/models/template.py:17 +#: shiftings/shifts/models/template_group.py:20 +#: shiftings/shifts/models/type.py:40 shiftings/shifts/models/type_group.py:16 +#: shiftings/shifts/templates/shifts/group/list.html:6 +#: shiftings/shifts/templates/shifts/list.html:6 +#: shiftings/shifts/templates/shifts/recurring/list.html:6 +#: shiftings/shifts/templates/shifts/template/group_list.html:6 +#: shiftings/shifts/templates/shifts/template/template_form.html:5 +#: shiftings/shifts/templates/shifts/type_group/list.html:7 msgid "Name" msgstr "Name" -#: src/shiftings/accounts/templates/accounts/user_detail.html:58 +#: shiftings/accounts/templates/accounts/user_detail.html:55 +#: shiftings/accounts/templates/accounts/user_detail.html:65 +msgid "Unknown" +msgstr "Unbekannt" + +#: shiftings/accounts/templates/accounts/user_detail.html:58 msgid "Set Display Name" msgstr "Setze Anzeigename" -#: src/shiftings/accounts/templates/accounts/user_detail.html:62 +#: shiftings/accounts/templates/accounts/user_detail.html:62 msgid "Email" msgstr "Email" -#: src/shiftings/accounts/templates/accounts/user_detail.html:64 +#: shiftings/accounts/templates/accounts/user_detail.html:64 msgid "Phone Number" msgstr "Telefonnummer" -#: src/shiftings/accounts/templates/accounts/user_detail.html:66 +#: shiftings/accounts/templates/accounts/user_detail.html:66 msgid "Member since" msgstr "Mitglied seit" -#: src/shiftings/accounts/templates/accounts/user_detail.html:68 +#: shiftings/accounts/templates/accounts/user_detail.html:68 msgid "Total Shifts" msgstr "Anzahl Schichten" -#: src/shiftings/accounts/templates/accounts/user_detail.html:70 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:124 +#: shiftings/accounts/templates/accounts/user_detail.html:70 +#: shiftings/organizations/templates/organizations/organization_admin.html:124 msgid "Groups" msgstr "Gruppen" -#: src/shiftings/accounts/templates/accounts/user_detail.html:71 -#: src/shiftings/events/templates/events/template/event.html:32 -#: src/shiftings/shifts/templates/shifts/template/template.html:19 +#: shiftings/accounts/templates/accounts/user_detail.html:71 +#: shiftings/events/templates/events/template/event.html:32 +#: shiftings/shifts/templates/shifts/template/template.html:19 msgid "None" msgstr "Keine" -#: src/shiftings/accounts/templates/accounts/user_detail.html:77 +#: shiftings/accounts/templates/accounts/user_detail.html:77 msgid "Organizations" msgstr "Organisationen" -#: src/shiftings/accounts/templates/accounts/user_detail.html:83 +#: shiftings/accounts/templates/accounts/user_detail.html:83 msgid "Hide organizations" msgstr "Organisationen einklappen" -#: src/shiftings/accounts/templates/accounts/user_detail.html:102 -#: src/shiftings/events/templates/events/template/event.html:12 +#: shiftings/accounts/templates/accounts/user_detail.html:102 +#: shiftings/events/templates/events/template/event.html:12 msgid "No Logo available" msgstr "Kein Logo verfügbar" -#: src/shiftings/accounts/templates/accounts/user_detail.html:125 +#: shiftings/accounts/templates/accounts/user_detail.html:125 msgid "Recent Shifts" msgstr "Letzte Schichten" -#: src/shiftings/accounts/templates/accounts/user_detail.html:127 -#: src/shiftings/accounts/templates/accounts/user_detail.html:132 +#: shiftings/accounts/templates/accounts/user_detail.html:127 +#: shiftings/accounts/templates/accounts/user_detail.html:132 msgid "Upcoming Shifts" msgstr "Anstehende Schichten" -#: src/shiftings/accounts/templates/accounts/user_detail.html:136 +#: shiftings/accounts/templates/accounts/user_detail.html:136 msgid "Past Shifts" msgstr "Vergangene Schichten" -#: src/shiftings/accounts/templates/accounts/user_detail.html:157 +#: shiftings/accounts/templates/accounts/user_detail.html:157 msgid "No recent Shifts" msgstr "Keine vergangenen Schichten" -#: src/shiftings/accounts/templates/accounts/user_detail.html:159 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:135 +#: shiftings/accounts/templates/accounts/user_detail.html:159 +#: shiftings/organizations/templates/organizations/organization_shifts.html:135 msgid "No upcoming shifts" msgstr "Keine anstehenden Schichten" -#: src/shiftings/accounts/templates/accounts/user_form.html:6 +#: shiftings/accounts/templates/accounts/user_form.html:6 msgid "Register" msgstr "Registrieren" -#: src/shiftings/accounts/templates/accounts/user_form.html:8 +#: shiftings/accounts/templates/accounts/user_form.html:8 #, python-format msgid "" "\n" @@ -1904,434 +316,440 @@ msgstr "" " Bearbeite Benutzer %(user)s\n" " " -#: src/shiftings/accounts/templates/accounts/user_form.html:19 -#: src/shiftings/templates/template/simple_form_modal.html:21 +#: shiftings/accounts/templates/accounts/user_form.html:19 +#: shiftings/templates/template/simple_form_modal.html:21 msgid "Submit" msgstr "Bestätigen" -#: src/shiftings/accounts/views/auth.py:83 +#: shiftings/accounts/views/auth.py:82 msgid "Error while creating the user instance!" msgstr "Fehler beim erstellen der Benutzerinstanz!" -#: src/shiftings/accounts/views/auth.py:87 +#: shiftings/accounts/views/auth.py:86 msgid "Successfully logged in!" msgstr "Erfolgreich eingeloggt!" -#: src/shiftings/accounts/views/auth.py:90 +#: shiftings/accounts/views/auth.py:89 #, python-format msgid "Error while trying to authenticate with sso! %(message)s" msgstr "Fehler beim Versuch gegen SSO zu authentifizieren! %(message)s" -#: src/shiftings/accounts/views/auth.py:131 -#: src/shiftings/templates/top_nav.html:52 +#: shiftings/accounts/views/auth.py:130 shiftings/templates/top_nav.html:55 msgid "Logout" msgstr "Ausloggen" -#: src/shiftings/accounts/views/password.py:13 +#: shiftings/accounts/views/password.py:13 msgid "Password Reset" msgstr "Passwort zurücksetzen" -#: src/shiftings/accounts/views/password.py:19 +#: shiftings/accounts/views/password.py:19 msgid "Confirm Password Reset" msgstr "Passwort Zurücksetzung bestätigen" -#: src/shiftings/accounts/views/user.py:35 -#: src/shiftings/templates/top_nav.html:15 +#: shiftings/accounts/views/user.py:35 shiftings/templates/top_nav.html:15 msgid "My Shifts" msgstr "Meine Schichten" -#: src/shiftings/accounts/views/user.py:100 +#: shiftings/accounts/views/user.py:101 msgid "Could not find your user." msgstr "Konnte deinen Benutzer nicht finden." -#: src/shiftings/accounts/views/user.py:106 +#: shiftings/accounts/views/user.py:107 msgid "Your EMail was confirmed. You can now login." msgstr "Deine EMail wurde bestätigt. Du kannst dich jetzt einloggen." -#: src/shiftings/accounts/views/user.py:109 +#: shiftings/accounts/views/user.py:110 msgid "Your activation link was invalid." msgstr "Dein Aktivierungslink war ungültig." -#: src/shiftings/accounts/views/user.py:126 +#: shiftings/accounts/views/user.py:127 msgid "Error while deleting your data: Please confirm deletion!" msgstr "Fehler beim Löschen deiner Daten: Bitte bestätige die Löschung!" -#: src/shiftings/cal/feed/event.py:44 +#: shiftings/cal/feed/event.py:44 msgid "Public Events" msgstr "Öffentliche Events" -#: src/shiftings/cal/forms/day_form.py:8 +#: shiftings/cal/forms/day_form.py:8 msgid "Select Day" msgstr "Tag auswählen" -#: src/shiftings/cal/templates/cal/calendar_base.html:14 -#: src/shiftings/cal/templates/cal/calendar_base.html:24 +#: shiftings/cal/templates/cal/calendar_base.html:14 +#: shiftings/cal/templates/cal/calendar_base.html:24 msgid "Day View" msgstr "Tagesansicht" -#: src/shiftings/cal/templates/cal/calendar_base.html:20 +#: shiftings/cal/templates/cal/calendar_base.html:20 msgid "Day (Detailed)" msgstr "Tag (Detailliert)" -#: src/shiftings/cal/templates/cal/calendar_base.html:30 +#: shiftings/cal/templates/cal/calendar_base.html:30 msgid "Day (By Types)" msgstr "Tag (Nach Typ)" -#: src/shiftings/cal/templates/cal/calendar_base.html:37 +#: shiftings/cal/templates/cal/calendar_base.html:37 msgid "Month View" msgstr "Monatsansicht" -#: src/shiftings/cal/templates/cal/calendar_base.html:38 -#: src/shiftings/utils/time/month.py:25 -#: src/shiftings/utils/time/timerange.py:12 +#: shiftings/cal/templates/cal/calendar_base.html:38 +#: shiftings/utils/time/month.py:25 shiftings/utils/time/timerange.py:12 msgid "Month" msgstr "Monat" -#: src/shiftings/cal/templates/cal/calendar_base.html:42 -#: src/shiftings/cal/templates/cal/calendar_base.html:48 +#: shiftings/cal/templates/cal/calendar_base.html:42 +#: shiftings/cal/templates/cal/calendar_base.html:48 msgid "List View" msgstr "Listenansicht" -#: src/shiftings/cal/templates/cal/calendar_base.html:44 +#: shiftings/cal/templates/cal/calendar_base.html:44 msgid "List (Detailed)" msgstr "Liste (Detailliert)" -#: src/shiftings/cal/templates/cal/calendar_base.html:50 +#: shiftings/cal/templates/cal/calendar_base.html:50 msgid "List (By Types)" msgstr "Liste (Nach Typ)" -#: src/shiftings/cal/templates/cal/calendar_templates/cal_shift_create_entry.html:4 -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:106 -#: src/shiftings/events/templates/events/event.html:74 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:376 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:120 +#: shiftings/cal/templates/cal/calendar_templates/cal_shift_create_entry.html:4 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:106 +#: shiftings/events/templates/events/event.html:74 +#: shiftings/organizations/templates/organizations/organization_admin.html:376 +#: shiftings/organizations/templates/organizations/organization_shifts.html:120 msgid "Create Shift" msgstr "Schicht erstellen" -#: src/shiftings/cal/templates/cal/calendar_templates/recurring_shift_entry.html:5 +#: shiftings/cal/templates/cal/calendar_templates/recurring_shift_entry.html:5 msgid "Recurring" msgstr "Wiederholend" -#: src/shiftings/cal/templates/cal/day_calendar.html:14 +#: shiftings/cal/templates/cal/day_calendar.html:14 msgid "Previous Day" msgstr "Letzter Tag" -#: src/shiftings/cal/templates/cal/day_calendar.html:22 +#: shiftings/cal/templates/cal/day_calendar.html:22 msgid "Jump to Date" msgstr "Zum Datum springen" -#: src/shiftings/cal/templates/cal/day_calendar.html:28 +#: shiftings/cal/templates/cal/day_calendar.html:28 msgid "Jump to Day" msgstr "Zum Tag springen" -#: src/shiftings/cal/templates/cal/day_calendar.html:30 +#: shiftings/cal/templates/cal/day_calendar.html:30 msgid "Select" msgstr "Auswählen" -#: src/shiftings/cal/templates/cal/day_calendar.html:38 +#: shiftings/cal/templates/cal/day_calendar.html:38 msgid "Next Day" msgstr "Nächster Tag" -#: src/shiftings/cal/templates/cal/list_calendar.html:12 +#: shiftings/cal/templates/cal/list_calendar.html:12 #, fuzzy #| msgid "Shifts" msgid "Shift list" -msgstr "Schichten" +msgstr "Schichtenliste" + +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:3 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:3 +#, python-format +msgid "" +"\n" +" Your query was capped to the server limit of %(max_entries)s. Please " +"filter more specifically.\n" +" " +msgstr "" +"\n" +" Deine Anfrage wurde auf %(max_entries)s Schichten begrenzt. Bitte gib " +"genauere Filter an.\n" +" " -#: src/shiftings/cal/templates/cal/template/day_calendar_shift_types.html:7 -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:48 -#: src/shiftings/shifts/templates/shifts/shift_participants.html:4 +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:14 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:48 +#: shiftings/shifts/templates/shifts/shift_participants.html:4 msgid "Add with Name to Shift" msgstr "Mit Namen zur Schicht hinzufügen" -#: src/shiftings/cal/templates/cal/template/day_calendar_shift_types.html:33 -#: src/shiftings/organizations/models/membership.py:12 +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:30 +#: shiftings/organizations/templates/organizations/organization_admin.html:197 +#: shiftings/shifts/templates/shifts/template/group.html:43 +#: shiftings/shifts/templates/shifts/template/group_template.html:23 +#: shiftings/shifts/templates/shifts/template/shift.html:10 +#: shiftings/shifts/templates/shifts/template/template_form.html:33 +msgid "Time" +msgstr "Zeit" + +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:40 +#: shiftings/organizations/models/membership.py:12 msgid "Default" msgstr "Standard" -#: src/shiftings/cal/templates/cal/template/day_calendar_shift_types.html:38 -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:95 +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:45 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:95 msgid "No Shifts found with these parameters or planned on this day" msgstr "" "An diesem Tag sind keine Schichten mit diesen Parametern gefunden oder " "geplant" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:3 -msgid "" -"\n" -" Your query was capped to the server limit of %(max_entries)s. Please " -"filter more specifically.\n" -" " -msgstr "" -"\n" -" Deine Anfrage wurde auf %(max_entries)s Schichten begrenzt. Bitte " -"gib genauere Filter an.\n" -" " - -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:12 -#: src/shiftings/events/models/event.py:24 -#: src/shiftings/shifts/models/base.py:12 -#: src/shiftings/shifts/models/recurring.py:34 -#: src/shiftings/shifts/models/template_group.py:24 -#: src/shiftings/shifts/models/type.py:37 -#: src/shiftings/shifts/models/type_group.py:14 -#: src/shiftings/shifts/templates/shifts/create_shift.html:79 -#: src/shiftings/shifts/templates/shifts/list.html:9 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:9 -#: src/shiftings/shifts/templates/shifts/shift.html:47 -#: src/shiftings/shifts/templates/shifts/template/group.html:39 -#: src/shiftings/shifts/templates/shifts/template/group.html:45 -#: src/shiftings/shifts/templates/shifts/template/group_list.html:8 -#: src/shiftings/shifts/templates/shifts/template/group_template.html:25 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:12 +#: shiftings/events/models/event.py:24 shiftings/shifts/models/base.py:12 +#: shiftings/shifts/models/recurring.py:34 +#: shiftings/shifts/models/template_group.py:24 +#: shiftings/shifts/models/type.py:37 shiftings/shifts/models/type_group.py:14 +#: shiftings/shifts/templates/shifts/create_shift.html:89 +#: shiftings/shifts/templates/shifts/list.html:9 +#: shiftings/shifts/templates/shifts/recurring/list.html:9 +#: shiftings/shifts/templates/shifts/shift.html:47 +#: shiftings/shifts/templates/shifts/template/group.html:39 +#: shiftings/shifts/templates/shifts/template/group.html:45 +#: shiftings/shifts/templates/shifts/template/group_list.html:8 +#: shiftings/shifts/templates/shifts/template/group_template.html:25 msgid "Organization" msgstr "Organisation" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:13 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:13 msgid "Org" msgstr "Org" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:14 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:14 msgid "Shift" msgstr "Schicht" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:15 -#: src/shiftings/shifts/templates/shifts/create_shift.html:56 -#: src/shiftings/shifts/templates/shifts/template/shift.html:12 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:21 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:15 +#: shiftings/shifts/templates/shifts/create_shift.html:66 +#: shiftings/shifts/templates/shifts/template/shift.html:12 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:21 msgid "Participants" msgstr "Teilnehmer" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:16 -#: src/shiftings/shifts/templates/shifts/list.html:10 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:16 +#: shiftings/shifts/templates/shifts/list.html:10 msgid "Start" msgstr "Start" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:17 -#: src/shiftings/shifts/templates/shifts/list.html:11 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:17 +#: shiftings/shifts/templates/shifts/list.html:11 msgid "End" msgstr "Ende" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:36 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:7 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:36 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:7 msgid "Shift Details" msgstr "Schicht Details" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:61 -#: src/shiftings/shifts/templates/shifts/shift_participants.html:23 -#: src/shiftings/shifts/templates/shifts/template/slots_display.html:29 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:44 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:61 +#: shiftings/shifts/templates/shifts/shift_participants.html:23 +#: shiftings/shifts/templates/shifts/template/slots_display.html:29 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:44 msgid "Add me" msgstr "Mich hinzufügen" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:112 -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:31 -#: src/shiftings/shifts/templates/shifts/edit_templates.html:26 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:112 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:31 +#: shiftings/shifts/templates/shifts/edit_templates.html:26 msgid "Add Shift" msgstr "Schicht hinzufügen" -#: src/shiftings/cal/templates/cal/template/month_calendar.html:7 +#: shiftings/cal/templates/cal/template/month_calendar.html:7 msgid "Previous Month" msgstr "Letzter Monat" -#: src/shiftings/cal/templates/cal/template/month_calendar.html:15 +#: shiftings/cal/templates/cal/template/month_calendar.html:15 msgid "Next Month" msgstr "Nächster Monat" -#: src/shiftings/cal/templates/cal/week_calendar.html:9 +#: shiftings/cal/templates/cal/week_calendar.html:9 msgid "Previous Week" msgstr "Letzte Woche" -#: src/shiftings/cal/templates/cal/week_calendar.html:13 +#: shiftings/cal/templates/cal/week_calendar.html:13 msgid "Week" msgstr "Woche" -#: src/shiftings/cal/templates/cal/week_calendar.html:17 +#: shiftings/cal/templates/cal/week_calendar.html:17 msgid "Next Week" msgstr "Nächste Woche" -#: src/shiftings/cal/views/day_calendar.py:24 +#: shiftings/cal/views/day_calendar.py:24 #, python-brace-format msgid "Day {date}" msgstr "Tag {date}" -#: src/shiftings/cal/views/day_calendar.py:25 +#: shiftings/cal/views/day_calendar.py:25 msgid "Day Overview" msgstr "Tagesübersicht" -#: src/shiftings/cal/views/list.py:24 -#: src/shiftings/organizations/templates/organizations/template/org_info.html:6 +#: shiftings/cal/views/list.py:24 +#: shiftings/organizations/templates/organizations/template/org_info.html:6 msgid "Shift Overview" msgstr "Schichtenübersicht" -#: src/shiftings/cal/views/month_calendar.py:24 +#: shiftings/cal/views/month_calendar.py:24 #, python-brace-format msgid "Month {year}/{month:0>2}" msgstr "Monat {year}/{month:0>2}" -#: src/shiftings/cal/views/month_calendar.py:26 -#: src/shiftings/events/templates/events/event.html:52 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:162 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:28 +#: shiftings/cal/views/month_calendar.py:26 +#: shiftings/events/templates/events/event.html:52 +#: shiftings/organizations/templates/organizations/organization_admin.html:162 +#: shiftings/organizations/templates/organizations/organization_shifts.html:28 msgid "Month Overview" msgstr "Monatsübersicht" -#: src/shiftings/events/forms/event.py:24 +#: shiftings/events/forms/event.py:24 #, python-format msgid "Start date %(start)s was after end_date %(end)s" msgstr "Startdatum %(start)s ist nach dem Enddatum %(end)s" -#: src/shiftings/events/models/event.py:26 -#: src/shiftings/events/templates/events/event.html:19 -#: src/shiftings/organizations/models/organization.py:25 -#: src/shiftings/organizations/templates/organizations/template/org_info.html:58 +#: shiftings/events/models/event.py:26 +#: shiftings/events/templates/events/event.html:19 +#: shiftings/organizations/models/organization.py:25 +#: shiftings/organizations/templates/organizations/template/org_info.html:58 msgid "Logo" msgstr "Logo" -#: src/shiftings/events/models/event.py:28 -#: src/shiftings/organizations/models/organization.py:27 +#: shiftings/events/models/event.py:28 +#: shiftings/organizations/models/organization.py:27 msgid "E-Mail" msgstr "E-Mail" -#: src/shiftings/events/models/event.py:30 -#: src/shiftings/organizations/models/organization.py:29 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:81 -#: src/shiftings/organizations/templates/organizations/template/organization.html:27 +#: shiftings/events/models/event.py:30 +#: shiftings/organizations/models/organization.py:29 +#: shiftings/organizations/templates/organizations/organization_shifts.html:81 +#: shiftings/organizations/templates/organizations/template/organization.html:27 msgid "Website" msgstr "Webseite" -#: src/shiftings/events/models/event.py:32 +#: shiftings/events/models/event.py:32 msgid "Start Date" msgstr "Startdatum" -#: src/shiftings/events/models/event.py:32 +#: shiftings/events/models/event.py:32 msgid "Earliest date where there are shifts available" msgstr "Frühestes Datum an dem Schichten verfügbar sind" -#: src/shiftings/events/models/event.py:33 +#: shiftings/events/models/event.py:33 msgid "End Date" msgstr "Enddatum" -#: src/shiftings/events/models/event.py:33 +#: shiftings/events/models/event.py:33 msgid "Latest date where there are shifts available" msgstr "Spätestes Datum an dem Schichten verfügbar sind" -#: src/shiftings/events/models/event.py:35 -#: src/shiftings/organizations/models/organization.py:32 +#: shiftings/events/models/event.py:35 +#: shiftings/organizations/models/organization.py:32 msgid "Description" msgstr "Beschreibung" -#: src/shiftings/events/models/event.py:55 +#: shiftings/events/models/event.py:55 #, python-brace-format msgid "{name} (by {organization})" msgstr "{name} (von {organization})" -#: src/shiftings/events/templates/events/event.html:10 +#: shiftings/events/templates/events/event.html:10 msgid "Edit Event" msgstr "Event bearbeiten" -#: src/shiftings/events/templates/events/event.html:55 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:165 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:31 +#: shiftings/events/templates/events/event.html:55 +#: shiftings/organizations/templates/organizations/organization_admin.html:165 +#: shiftings/organizations/templates/organizations/organization_shifts.html:31 msgid "Complete Overview" msgstr "Komplette Übersicht" -#: src/shiftings/events/templates/events/event.html:69 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:363 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:109 +#: shiftings/events/templates/events/event.html:69 +#: shiftings/organizations/templates/organizations/organization_admin.html:363 +#: shiftings/organizations/templates/organizations/organization_shifts.html:109 msgid "Shifts" msgstr "Schichten" -#: src/shiftings/events/templates/events/event.html:71 -#: src/shiftings/events/templates/events/event.html:72 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:371 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:112 +#: shiftings/events/templates/events/event.html:71 +#: shiftings/events/templates/events/event.html:72 +#: shiftings/organizations/templates/organizations/organization_admin.html:371 +#: shiftings/organizations/templates/organizations/organization_shifts.html:112 msgid "ical Export" msgstr "ical Export" -#: src/shiftings/events/templates/events/event.html:86 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:389 -#: src/shiftings/shifts/templates/shifts/list.html:7 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:7 -#: src/shiftings/shifts/templates/shifts/template/shift.html:8 -#: src/shiftings/shifts/templates/shifts/template/template.html:7 +#: shiftings/events/templates/events/event.html:86 +#: shiftings/organizations/templates/organizations/organization_admin.html:389 +#: shiftings/shifts/templates/shifts/list.html:7 +#: shiftings/shifts/templates/shifts/recurring/list.html:7 +#: shiftings/shifts/templates/shifts/template/shift.html:8 +#: shiftings/shifts/templates/shifts/template/template.html:7 msgid "Type" msgstr "Typ" -#: src/shiftings/events/templates/events/event.html:87 -#: src/shiftings/mail/forms/mail.py:39 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:241 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:390 -#: src/shiftings/shifts/models/template_group.py:26 -#: src/shiftings/shifts/templates/shifts/shift.html:43 -#: src/shiftings/shifts/templates/shifts/template/group_list.html:9 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:17 -#: src/shiftings/shifts/templates/shifts/template/template.html:13 +#: shiftings/events/templates/events/event.html:87 +#: shiftings/mail/forms/mail.py:55 +#: shiftings/organizations/templates/organizations/organization_admin.html:241 +#: shiftings/organizations/templates/organizations/organization_admin.html:390 +#: shiftings/shifts/models/template_group.py:26 +#: shiftings/shifts/templates/shifts/shift.html:43 +#: shiftings/shifts/templates/shifts/template/group_list.html:9 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:17 +#: shiftings/shifts/templates/shifts/template/template.html:13 msgid "Start Time" msgstr "Startzeit" -#: src/shiftings/events/templates/events/event.html:88 -#: src/shiftings/mail/forms/mail.py:40 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:391 -#: src/shiftings/shifts/templates/shifts/shift.html:45 -#: src/shiftings/shifts/templates/shifts/template/template.html:15 +#: shiftings/events/templates/events/event.html:88 +#: shiftings/mail/forms/mail.py:56 +#: shiftings/organizations/templates/organizations/organization_admin.html:391 +#: shiftings/shifts/templates/shifts/shift.html:45 +#: shiftings/shifts/templates/shifts/template/template.html:15 msgid "End Time" msgstr "Endzeit" -#: src/shiftings/events/templates/events/event.html:89 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:198 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:240 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:392 -#: src/shiftings/shifts/models/base.py:9 -#: src/shiftings/shifts/models/template_group.py:21 -#: src/shiftings/shifts/templates/shifts/list.html:8 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:8 -#: src/shiftings/shifts/templates/shifts/shift.html:41 -#: src/shiftings/shifts/templates/shifts/template/group.html:41 -#: src/shiftings/shifts/templates/shifts/template/group_list.html:7 -#: src/shiftings/shifts/templates/shifts/template/group_template.html:21 -#: src/shiftings/shifts/templates/shifts/template/shift_card.html:12 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:18 +#: shiftings/events/templates/events/event.html:89 +#: shiftings/organizations/templates/organizations/organization_admin.html:198 +#: shiftings/organizations/templates/organizations/organization_admin.html:240 +#: shiftings/organizations/templates/organizations/organization_admin.html:392 +#: shiftings/shifts/models/base.py:9 +#: shiftings/shifts/models/template_group.py:21 +#: shiftings/shifts/templates/shifts/list.html:8 +#: shiftings/shifts/templates/shifts/recurring/list.html:8 +#: shiftings/shifts/templates/shifts/shift.html:41 +#: shiftings/shifts/templates/shifts/template/group.html:41 +#: shiftings/shifts/templates/shifts/template/group_list.html:7 +#: shiftings/shifts/templates/shifts/template/group_template.html:21 +#: shiftings/shifts/templates/shifts/template/shift_card.html:12 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:18 msgid "Place" msgstr "Ort" -#: src/shiftings/events/templates/events/event.html:90 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:393 +#: shiftings/events/templates/events/event.html:90 +#: shiftings/organizations/templates/organizations/organization_admin.html:393 msgid "Users/Required/Max" msgstr "Benutzer/Benötigt/Maximum" -#: src/shiftings/events/templates/events/event.html:108 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:416 +#: shiftings/events/templates/events/event.html:108 +#: shiftings/organizations/templates/organizations/organization_admin.html:416 msgid "No current shifts" msgstr "Keine aktuellen Schichten" -#: src/shiftings/events/templates/events/list.html:5 -#: src/shiftings/events/templates/events/list.html:13 -#: src/shiftings/events/templates/events/list.html:22 +#: shiftings/events/templates/events/list.html:5 +#: shiftings/events/templates/events/list.html:13 +#: shiftings/events/templates/events/list.html:22 msgid "All Events" msgstr "Alle Events" -#: src/shiftings/events/templates/events/list.html:7 -#: src/shiftings/templates/top_nav.html:22 +#: shiftings/events/templates/events/list.html:7 +#: shiftings/templates/top_nav.html:22 msgid "Current Events" msgstr "Aktuelle Events" -#: src/shiftings/events/templates/events/list.html:16 +#: shiftings/events/templates/events/list.html:16 msgid "All upcoming Events" msgstr "Alle anstehenden Events" -#: src/shiftings/events/templates/events/list.html:20 +#: shiftings/events/templates/events/list.html:20 msgid "Enter Name or Organization" msgstr "Name oder Organisation angeben" -#: src/shiftings/events/templates/events/list.html:26 +#: shiftings/events/templates/events/list.html:26 msgid "Future Events" msgstr "Zukünftige Events" -#: src/shiftings/events/templates/events/list.html:39 +#: shiftings/events/templates/events/list.html:39 msgid "No Events found." msgstr "Keine Events gefunden." -#: src/shiftings/events/templates/events/template/event.html:23 +#: shiftings/events/templates/events/template/event.html:23 #, python-format msgid "" "\n" @@ -2342,69 +760,81 @@ msgstr "" " %(start_date)s bis %(end_date)s\n" " " -#: src/shiftings/events/templates/events/template/event.html:31 +#: shiftings/events/templates/events/template/event.html:31 msgid "Open Shifts" msgstr "Offene Schichten" -#: src/shiftings/events/templates/events/template/event.html:32 +#: shiftings/events/templates/events/template/event.html:32 msgid "Required People" msgstr "Benötigte Personen" -#: src/shiftings/events/templates/events/template/event.html:33 +#: shiftings/events/templates/events/template/event.html:33 msgid "Visibility" msgstr "Sichtbarkeit" -#: src/shiftings/events/templates/events/template/event.html:33 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:350 +#: shiftings/events/templates/events/template/event.html:33 +#: shiftings/organizations/templates/organizations/organization_admin.html:350 msgid "Public,Private,Unknown" msgstr "Öffentlich,Privat,Unbekannt" -#: src/shiftings/events/templates/events/template/event_stats.html:2 +#: shiftings/events/templates/events/template/event_stats.html:2 msgid "Shifts that are below the required amount of users" msgstr "Schichten mit weniger als den benötigten Benutzern" -#: src/shiftings/events/templates/events/template/event_stats.html:3 +#: shiftings/events/templates/events/template/event_stats.html:3 msgid "Shifts missing users" msgstr "Schichten mit fehlenden Benutzern" -#: src/shiftings/events/templates/events/template/event_stats.html:6 +#: shiftings/events/templates/events/template/event_stats.html:6 msgid "Shifts that are below the required and maximum amount of users" msgstr "Schichten unter der benötigten und maximalen Benutzeranzahl" -#: src/shiftings/events/templates/events/template/event_stats.html:7 +#: shiftings/events/templates/events/template/event_stats.html:7 msgid "Shifts with open slots" msgstr "Schichten mit offenen Positionen" -#: src/shiftings/events/templates/events/template/event_stats.html:10 +#: shiftings/events/templates/events/template/event_stats.html:10 msgid "Number of Shift slots filled with users" msgstr "Anzahl mit Benutzern gefüllte Positionen" -#: src/shiftings/events/templates/events/template/event_stats.html:11 +#: shiftings/events/templates/events/template/event_stats.html:11 msgid "Filled Slots" msgstr "Gefüllte Positionen" -#: src/shiftings/events/templates/events/template/event_stats.html:12 -#: src/shiftings/events/templates/events/template/event_stats.html:16 +#: shiftings/events/templates/events/template/event_stats.html:12 +#: shiftings/events/templates/events/template/event_stats.html:16 msgid "No Shifts available" msgstr "Keine Schichten verfügbar" -#: src/shiftings/events/templates/events/template/event_stats.html:14 +#: shiftings/events/templates/events/template/event_stats.html:14 msgid "Number of Shift slots that are still open" msgstr "Anzahl offene Postiionen" -#: src/shiftings/events/templates/events/template/event_stats.html:15 +#: shiftings/events/templates/events/template/event_stats.html:15 msgid "Open Slots" msgstr "Offene Postionen" -#: src/shiftings/mail/forms/mail.py:10 +#: shiftings/mail/forms/mail.py:13 msgid "Subject" msgstr "Betreff" -#: src/shiftings/mail/forms/mail.py:12 +#: shiftings/mail/forms/mail.py:14 +msgid "Text" +msgstr "Text" + +#: shiftings/mail/forms/mail.py:15 msgid "Attachments" msgstr "Anhänge" -#: src/shiftings/mail/forms/mail.py:22 +#: shiftings/mail/forms/mail.py:26 +msgid "Each attachment must be smaller than 10 MB." +msgstr "" + +#: shiftings/mail/forms/mail.py:30 +msgid "Total attachment size must be smaller than 25 MB." +msgstr "" + +#: shiftings/mail/forms/mail.py:38 msgid "" "Send the mail only to specific membership types. Or leave blank to send to " "all." @@ -2412,7 +842,7 @@ msgstr "" "Email nur an bestimmte Mitgliedertypen senden oder leerlassen um sie an alle " "zu schicken." -#: src/shiftings/mail/forms/mail.py:37 +#: shiftings/mail/forms/mail.py:53 #, fuzzy #| msgid "" #| "Send the mail only to specific membership types. Or leave blank to send " @@ -2423,193 +853,195 @@ msgstr "" "Email nur an bestimmte Mitgliedertypen senden oder leerlassen um sie an alle " "zu schicken." -#: src/shiftings/mail/templates/mail/mail.html:8 +#: shiftings/mail/forms/mail.py:71 +msgid "Start time must be before end time." +msgstr "" + +#: shiftings/mail/templates/mail/mail.html:8 msgid "Send Mail" msgstr "Email versenden" -#: src/shiftings/mail/views/mail.py:45 +#: shiftings/mail/views/mail.py:45 #, python-brace-format msgid "E-Mail sent to {count} user(s)." msgstr "Email an {count} Benutzer verschickt." -#: src/shiftings/organizations/forms/membership.py:40 -#: src/shiftings/shifts/forms/participant.py:57 +#: shiftings/organizations/forms/membership.py:40 +#: shiftings/shifts/forms/participant.py:57 msgid "The user you entered could not be found." msgstr "Der eingetragene Benutzer konnte nicht gefunden werden." -#: src/shiftings/organizations/models/membership.py:11 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:68 -#: src/shiftings/templates/top_nav.html:50 +#: shiftings/organizations/models/membership.py:11 +#: shiftings/organizations/templates/organizations/organization_admin.html:68 +#: shiftings/templates/top_nav.html:51 msgid "Admin" msgstr "Admin" -#: src/shiftings/organizations/models/membership.py:13 +#: shiftings/organizations/models/membership.py:13 msgid "Permissions" msgstr "Berechtigungen" -#: src/shiftings/organizations/models/membership.py:19 +#: shiftings/organizations/models/membership.py:19 msgid "edit organization details" msgstr "Organisationdetails bearbeiten" -#: src/shiftings/organizations/models/membership.py:20 +#: shiftings/organizations/models/membership.py:20 msgid "see members of the organization" msgstr "Mitglieder der Organisation sehen" -#: src/shiftings/organizations/models/membership.py:21 +#: shiftings/organizations/models/membership.py:21 msgid "see shift participation statistics" msgstr "Schicht Teilnahmestatistik der Organistation sehen" -#: src/shiftings/organizations/models/membership.py:22 +#: shiftings/organizations/models/membership.py:22 msgid "send emails to everyone in the organization" msgstr "Emails an alle in der Organisation senden" -#: src/shiftings/organizations/models/membership.py:24 +#: shiftings/organizations/models/membership.py:24 msgid "create and update membership types for the organization" msgstr "Mitgliedschaftstypen in der Organisation erstellen und ändern" -#: src/shiftings/organizations/models/membership.py:25 +#: shiftings/organizations/models/membership.py:25 msgid "add and remove members for the organization" msgstr "Mitglieder in der Organisation hinzufügen und entfernen" -#: src/shiftings/organizations/models/membership.py:27 +#: shiftings/organizations/models/membership.py:27 msgid "create and update events for the organization" msgstr "Events für die Organisation erstellen und bearbeiten" -#: src/shiftings/organizations/models/membership.py:29 +#: shiftings/organizations/models/membership.py:29 msgid "create and update recurring shifts for the organization" msgstr "Wiederkehrende Schichten für die Organisation erstellen und bearbeiten" -#: src/shiftings/organizations/models/membership.py:30 +#: shiftings/organizations/models/membership.py:30 msgid "create and update shifts templates for the organization" msgstr "Schichtvorlagen für die Organisation erstellen und bearbeiten" -#: src/shiftings/organizations/models/membership.py:31 +#: shiftings/organizations/models/membership.py:31 msgid "create and update shifts for the organization" msgstr "Schichten für die Organisation erstellen und bearbeiten" -#: src/shiftings/organizations/models/membership.py:32 +#: shiftings/organizations/models/membership.py:32 msgid "delete uncompleted shifts for the organization" msgstr "Unabgeschlossene Schichten der Organistation löschen" -#: src/shiftings/organizations/models/membership.py:34 +#: shiftings/organizations/models/membership.py:34 msgid "remove others from shifts" msgstr "Andere von Schichten entfernen" -#: src/shiftings/organizations/models/membership.py:35 +#: shiftings/organizations/models/membership.py:35 msgid "add other users that are not members of the organization to shifts" msgstr "" "Andere Benutzer zu Schichten hinzufügen, die keine Mitglieder der " "Organisation sind" -#: src/shiftings/organizations/models/membership.py:36 +#: shiftings/organizations/models/membership.py:36 msgid "add other organization members to shifts" msgstr "Andere Organisationsmitglieder zu Schichten hinzufügen" -#: src/shiftings/organizations/models/membership.py:37 +#: shiftings/organizations/models/membership.py:37 msgid "participate in shifts" msgstr "An Schichten teilnehmen" -#: src/shiftings/organizations/models/membership.py:38 +#: shiftings/organizations/models/membership.py:38 msgid "add self/others (depending on other permissions) to past shifts" msgstr "" -#: src/shiftings/organizations/models/membership.py:57 +#: shiftings/organizations/models/membership.py:57 #, python-brace-format msgid "{name} (Admin)" msgstr "{name} (Admin)" -#: src/shiftings/organizations/models/membership.py:59 +#: shiftings/organizations/models/membership.py:59 #, python-brace-format msgid "{name} (Default)" msgstr "{name} (Standard)" -#: src/shiftings/organizations/models/membership.py:65 +#: shiftings/organizations/models/membership.py:65 msgid "Membership Type" msgstr "Mitgliedschaftstyp" -#: src/shiftings/organizations/models/membership.py:69 -#: src/shiftings/shifts/models/type.py:38 +#: shiftings/organizations/models/membership.py:69 +#: shiftings/shifts/models/type.py:38 msgid "Group" msgstr "Gruppe" -#: src/shiftings/organizations/models/membership.py:100 +#: shiftings/organizations/models/membership.py:100 msgid "Membership can only be either user or group, not both." msgstr "" "Mitgliedschaft kann nur für Benutzer oder Gruppe eingetragen werden nicht " "beides auf einmal." -#: src/shiftings/organizations/models/membership.py:102 +#: shiftings/organizations/models/membership.py:102 msgid "Membership must consist of a user or a group." msgstr "Mitgliedschaft muss aus einem Benutzer oder einer Gruppe bestehen." -#: src/shiftings/organizations/models/organization.py:30 -msgid "Include Protocol i.E. https://example.com" -msgstr "Inklusive Protokoll z.B. https://example.com" +#: shiftings/organizations/models/organization.py:30 +msgid "Include protocol, e.g." +msgstr "" -#: src/shiftings/organizations/models/organization.py:33 -#: src/shiftings/shifts/models/base.py:24 -#: src/shiftings/shifts/models/recurring.py:56 -#: src/shiftings/shifts/models/recurring.py:63 -#: src/shiftings/shifts/models/shift.py:32 -#: src/shiftings/shifts/models/template.py:33 +#: shiftings/organizations/models/organization.py:33 +#: shiftings/shifts/models/base.py:24 shiftings/shifts/models/recurring.py:56 +#: shiftings/shifts/models/recurring.py:63 shiftings/shifts/models/shift.py:32 +#: shiftings/shifts/models/template.py:33 #, python-brace-format msgid "A maximum of {amount} characters is allowed" msgstr "Es sind maximal {amount} Zeichen erlaubt" -#: src/shiftings/organizations/models/organization.py:34 +#: shiftings/organizations/models/organization.py:34 msgid "Confirm Participants" msgstr "Teilnehmer bestätigen" -#: src/shiftings/organizations/models/organization.py:35 +#: shiftings/organizations/models/organization.py:35 msgid "Whether the participants can be confirmed or not. Default: False" msgstr "Telnehmer können bestätigt werden. Standard: False" -#: src/shiftings/organizations/models/organization.py:49 +#: shiftings/organizations/models/organization.py:49 msgid "manage all organizations" msgstr "Alle Organisationen bearbeiten" -#: src/shiftings/organizations/models/user.py:10 +#: shiftings/organizations/models/user.py:10 msgid "Claimed by" msgstr "Beansprucht von" -#: src/shiftings/organizations/templates/organizations/list.html:5 +#: shiftings/organizations/templates/organizations/list.html:5 msgid "All Organizations" msgstr "Alle Organisationen" -#: src/shiftings/organizations/templates/organizations/list.html:7 -#: src/shiftings/templates/top_nav.html:18 +#: shiftings/organizations/templates/organizations/list.html:7 +#: shiftings/templates/top_nav.html:18 msgid "My Organizations" msgstr "Meine Organisationen" -#: src/shiftings/organizations/templates/organizations/list.html:12 +#: shiftings/organizations/templates/organizations/list.html:12 msgid "Enter Name" msgstr "Name einegeben" -#: src/shiftings/organizations/templates/organizations/list.html:14 +#: shiftings/organizations/templates/organizations/list.html:14 msgid "Add new Organization" msgstr "Neue Organisation hinzufügen" -#: src/shiftings/organizations/templates/organizations/list.html:25 +#: shiftings/organizations/templates/organizations/list.html:25 msgid "No Organizations found." msgstr "Keine Organisationen gefunden." -#: src/shiftings/organizations/templates/organizations/organization_admin.html:6 +#: shiftings/organizations/templates/organizations/organization_admin.html:6 msgid "Show membership Permissions" msgstr "Mitgliedschaftsberechtigungen anzeigen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:10 +#: shiftings/organizations/templates/organizations/organization_admin.html:10 msgid "All Permissions" msgstr "Alle Berechtigungen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:19 +#: shiftings/organizations/templates/organizations/organization_admin.html:19 msgid "No permissions" msgstr "Keine Berechtigungen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:26 +#: shiftings/organizations/templates/organizations/organization_admin.html:26 msgid "Add member" msgstr "Mitglied hinzufügen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:41 +#: shiftings/organizations/templates/organizations/organization_admin.html:41 #, python-format msgid "" "\n" @@ -2620,183 +1052,187 @@ msgstr "" " Mitglieder von %(organization)s\n" " " -#: src/shiftings/organizations/templates/organizations/organization_admin.html:48 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:8 +#: shiftings/organizations/templates/organizations/organization_admin.html:48 +#: shiftings/organizations/templates/organizations/organization_shifts.html:8 msgid "Member Shift Summary" msgstr "Mitglieder Schichten Zusammenfassung" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:54 +#: shiftings/organizations/templates/organizations/organization_admin.html:54 msgid "Add Membership Type" msgstr "Mitgliedschaftstyp hinzufügen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:78 +#: shiftings/organizations/templates/organizations/organization_admin.html:78 msgid "Show user permissions" msgstr "Benutzerberechtigungen anzeigen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:81 +#: shiftings/organizations/templates/organizations/organization_admin.html:81 msgid "Edit Membership Type" msgstr "Mitgliedschaftstyp bearbeiten" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:90 +#: shiftings/organizations/templates/organizations/organization_admin.html:90 msgid "Add Member" msgstr "Mitglied hinzufügen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:100 +#: shiftings/organizations/templates/organizations/organization_admin.html:100 msgid "No Members of this type!" msgstr "Keine Mitglieder von diesem Typ!" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:106 +#: shiftings/organizations/templates/organizations/organization_admin.html:106 msgid "Direct Members" msgstr "Direkte Mitglieder" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:150 +#: shiftings/organizations/templates/organizations/organization_admin.html:150 msgid "Next Shift" msgstr "Nächste Schicht" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:154 +#: shiftings/organizations/templates/organizations/organization_admin.html:154 msgid "No Shifts planned" msgstr "Keine Schichten geplant" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:179 -#: src/shiftings/shifts/templates/shifts/template/group.html:52 +#: shiftings/organizations/templates/organizations/organization_admin.html:179 +#: shiftings/shifts/templates/shifts/template/group.html:52 msgid "Recurring Shifts" msgstr "Wiederkehrende Schichten" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:184 +#: shiftings/organizations/templates/organizations/organization_admin.html:184 msgid "Add recurring shift" msgstr "Wiederkehrende Schicht hinzufügen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:196 +#: shiftings/organizations/templates/organizations/organization_admin.html:196 msgid "Shift count" msgstr "Schichten Anzahl" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:199 +#: shiftings/organizations/templates/organizations/organization_admin.html:199 msgid "Status" msgstr "Status" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:210 +#: shiftings/organizations/templates/organizations/organization_admin.html:210 msgid "Inactive,Active,Undefined" msgstr "Inaktiv,Aktiv,Unbekannt" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:216 +#: shiftings/organizations/templates/organizations/organization_admin.html:216 msgid "No recurring shifts" msgstr "Keine wiederkehrenden Schichten" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:223 +#: shiftings/organizations/templates/organizations/organization_admin.html:223 msgid "Shifts Templates" msgstr "Schichtvorlagen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:228 +#: shiftings/organizations/templates/organizations/organization_admin.html:228 msgid "Add shifts template" msgstr "Schichtvorlagen hinzufügen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:255 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:309 +#: shiftings/organizations/templates/organizations/organization_admin.html:255 +#: shiftings/organizations/templates/organizations/organization_admin.html:309 msgid "No shifts templates" msgstr "Keine Schichtvorlagen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:262 +#: shiftings/organizations/templates/organizations/organization_admin.html:262 msgid "Shifts Types" msgstr "Schichttypen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:267 +#: shiftings/organizations/templates/organizations/organization_admin.html:267 msgid "Add Shift Type" msgstr "Schichttyp hinzufügen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:279 +#: shiftings/organizations/templates/organizations/organization_admin.html:279 msgid "Color" msgstr "Farbe" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:280 -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:15 +#: shiftings/organizations/templates/organizations/organization_admin.html:280 +#: shiftings/organizations/templates/organizations/user/claim_users.html:15 msgid "Shift Count" msgstr "Schichten Anzahl" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:281 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:41 -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:16 +#: shiftings/organizations/templates/organizations/organization_admin.html:281 +#: shiftings/organizations/templates/organizations/organization_settings.html:41 +#: shiftings/organizations/templates/organizations/user/claim_users.html:16 msgid "Action" msgstr "Aktion" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:290 +#: shiftings/organizations/templates/organizations/organization_admin.html:290 msgid "Shift Type Background Color" msgstr "Schichttyp Hintergrundfarbe" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:317 +#: shiftings/organizations/templates/organizations/organization_admin.html:317 msgid "Upcoming Events" msgstr "Anstehende Events" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:321 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:50 +#: shiftings/organizations/templates/organizations/organization_admin.html:321 +#: shiftings/organizations/templates/organizations/organization_shifts.html:50 msgid "Expired Events" msgstr "Abgelaufene Events" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:326 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:55 +#: shiftings/organizations/templates/organizations/organization_admin.html:326 +#: shiftings/organizations/templates/organizations/organization_shifts.html:55 msgid "Add Event" msgstr "Event hinzufügen" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:356 +#: shiftings/organizations/templates/organizations/organization_admin.html:356 msgid "No upcoming Events planned" msgstr "Keine anstehenden Events" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:367 +#: shiftings/organizations/templates/organizations/organization_admin.html:367 msgid "Claim Legacy Shifts" msgstr "Beanspruche alte Schichten" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:19 -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:36 -#: src/shiftings/shifts/templates/shifts/edit_templates.html:31 -#: src/shiftings/shifts/templates/shifts/recurring/form.html:25 -#: src/shiftings/templates/generic/create_or_update.html:20 -#: src/shiftings/templates/generic/formset.html:17 +#: shiftings/organizations/templates/organizations/organization_settings.html:19 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:36 +#: shiftings/shifts/templates/shifts/edit_templates.html:31 +#: shiftings/shifts/templates/shifts/recurring/form.html:25 +#: shiftings/templates/generic/create_or_update.html:20 +#: shiftings/templates/generic/formset.html:17 msgid "Save" msgstr "Speichern" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:26 +#: shiftings/organizations/templates/organizations/organization_settings.html:26 msgid "Shift Type Groups" msgstr "Schichttypgruppen" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:29 +#: shiftings/organizations/templates/organizations/organization_settings.html:29 msgid "Create Type Group" msgstr "Schichttypgruppe hinzufügen" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:40 +#: shiftings/organizations/templates/organizations/organization_settings.html:38 +msgid "Order" +msgstr "Reihenfolge" + +#: shiftings/organizations/templates/organizations/organization_settings.html:40 msgid "Types" msgstr "Typen" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:61 +#: shiftings/organizations/templates/organizations/organization_settings.html:61 msgid "Edit Type Group" msgstr "Typgruppe editieren" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:72 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:77 +#: shiftings/organizations/templates/organizations/organization_settings.html:72 +#: shiftings/organizations/templates/organizations/organization_settings.html:77 msgid "Move Up" msgstr "Nach oben" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:84 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:89 +#: shiftings/organizations/templates/organizations/organization_settings.html:84 +#: shiftings/organizations/templates/organizations/organization_settings.html:89 msgid "Move Down" msgstr "Nach unten" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:11 +#: shiftings/organizations/templates/organizations/organization_shifts.html:11 msgid "Full Summary" msgstr "Komplette Zusammenfassung" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:46 +#: shiftings/organizations/templates/organizations/organization_shifts.html:46 msgid "Upcoming Event" msgstr "Anstehendes Event" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:66 -#: src/shiftings/shifts/models/shift.py:21 +#: shiftings/organizations/templates/organizations/organization_shifts.html:66 +#: shiftings/shifts/models/shift.py:21 msgid "Event" msgstr "Event" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:68 +#: shiftings/organizations/templates/organizations/organization_shifts.html:68 msgid "Go to Event" msgstr "Zum Event springen" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:74 +#: shiftings/organizations/templates/organizations/organization_shifts.html:74 #, python-format msgid "" "\n" @@ -2811,128 +1247,126 @@ msgstr "" "td>\n" " " -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:94 +#: shiftings/organizations/templates/organizations/organization_shifts.html:94 msgid "No Events planned" msgstr "Keine geplanten Events" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:102 +#: shiftings/organizations/templates/organizations/organization_shifts.html:102 msgid "Events disabled" msgstr "Events deaktiviert" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:117 +#: shiftings/organizations/templates/organizations/organization_shifts.html:117 msgid "Create Shift from Template" msgstr "Schichten aus Vorlage erstellen" -#: src/shiftings/organizations/templates/organizations/template/member_group.html:6 +#: shiftings/organizations/templates/organizations/template/member_group.html:6 msgid "Click to expand members" msgstr "Klicke für detaillierte Mitgliederansicht" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:8 +#: shiftings/organizations/templates/organizations/template/org_info.html:8 msgid "Organization Overview" msgstr "Organisationsübersicht" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:12 +#: shiftings/organizations/templates/organizations/template/org_info.html:12 msgid "Administrate Organization" msgstr "Organisation administrieren" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:14 +#: shiftings/organizations/templates/organizations/template/org_info.html:14 msgid "Organization Admin View" msgstr "Organisation Admin Ansicht" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:19 +#: shiftings/organizations/templates/organizations/template/org_info.html:19 msgid "Organization Settings" msgstr "Organisationseinstellungen" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:21 +#: shiftings/organizations/templates/organizations/template/org_info.html:21 msgid "Organization Settings View" msgstr "Organisations Einstellungen" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:35 +#: shiftings/organizations/templates/organizations/template/org_info.html:35 #, fuzzy #| msgid "See Shift Participants" msgid "Send Mail to shift participants" msgstr "Schichtteilnehmer sehen" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:41 +#: shiftings/organizations/templates/organizations/template/org_info.html:41 msgid "Edit Organization" msgstr "Organisation bearbeiten" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:47 +#: shiftings/organizations/templates/organizations/template/org_info.html:47 msgid "Update Shift Permissions" msgstr "Schichtberechtigungen aktualisieren" -#: src/shiftings/organizations/templates/organizations/template/organization.html:12 +#: shiftings/organizations/templates/organizations/template/organization.html:12 msgid "No Image available" msgstr "Kein Bild verfügbar" -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:6 +#: shiftings/organizations/templates/organizations/user/claim_users.html:6 msgid "Imported Users" msgstr "Importierte Benutzer" -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:14 +#: shiftings/organizations/templates/organizations/user/claim_users.html:14 msgid "Claimed" msgstr "Beansprucht" -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:36 +#: shiftings/organizations/templates/organizations/user/claim_users.html:36 msgid "Claim" msgstr "Beanspruchen" -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:43 +#: shiftings/organizations/templates/organizations/user/claim_users.html:43 msgid "Unclaim" msgstr "Freigeben" -#: src/shiftings/organizations/views/membership.py:55 -#: src/shiftings/organizations/views/membership_type.py:57 +#: shiftings/organizations/views/membership.py:55 +#: shiftings/organizations/views/membership_type.py:57 msgid "Membership removed" msgstr "Mitgliedschaft entfernt" -#: src/shiftings/organizations/views/organization.py:62 +#: shiftings/organizations/views/organization.py:62 #, python-brace-format msgid "{org} Overview" msgstr "{org}Übersicht" -#: src/shiftings/organizations/views/organization.py:89 +#: shiftings/organizations/views/organization.py:89 #, python-brace-format msgid "{org} Administration" msgstr "{org} Administration" -#: src/shiftings/organizations/views/organization.py:135 +#: shiftings/organizations/views/organization.py:135 #, python-brace-format msgid "{org} Settings" msgstr "{org} Einstellungen" -#: src/shiftings/organizations/views/user.py:36 +#: shiftings/organizations/views/user.py:36 msgid "You can't claim users that are already claimed." msgstr "Du kannst keine Benutzer beanspruchen, die bereits beansprucht sind." -#: src/shiftings/shifts/forms/filters.py:12 +#: shiftings/shifts/forms/filters.py:12 msgid "Shifts I participate in" msgstr "Schichten, an denen ich teilnehme" -#: src/shiftings/shifts/forms/filters.py:17 -#: src/shiftings/shifts/forms/filters.py:19 +#: shiftings/shifts/forms/filters.py:17 shiftings/shifts/forms/filters.py:19 msgid "Date YYYY-MM-DD" msgstr "Datum YYYY-MM-DD" -#: src/shiftings/shifts/forms/filters.py:18 -#: src/shiftings/shifts/forms/filters.py:20 +#: shiftings/shifts/forms/filters.py:18 shiftings/shifts/forms/filters.py:20 msgid "Time HH:MM" msgstr "Uhrzeit HH:MM" -#: src/shiftings/shifts/forms/participant.py:28 +#: shiftings/shifts/forms/participant.py:28 #, python-brace-format msgid "User {user} is already registered for this shift." msgstr "Benutzer {user} wurde bereits für diese Schicht eingetragen." -#: src/shiftings/shifts/forms/participant.py:32 +#: shiftings/shifts/forms/participant.py:32 msgid "Organization Users" msgstr "Organisationsmitglieder" -#: src/shiftings/shifts/forms/participant.py:33 +#: shiftings/shifts/forms/participant.py:33 msgid "Other Users" msgstr "Andere Benutzer" -#: src/shiftings/shifts/forms/participant.py:34 +#: shiftings/shifts/forms/participant.py:34 msgid "" "To add users that do not belong to your organization please enter their " "username." @@ -2940,21 +1374,21 @@ msgstr "" "Um Benutzer, die nicht Teil der Organisation sind, hinzuzufügen gib deren " "Benutzernamen an." -#: src/shiftings/shifts/forms/participant.py:65 +#: shiftings/shifts/forms/participant.py:65 msgid "Only select one type of user!" msgstr "Wähle nur einen Benutzertyp aus!" -#: src/shiftings/shifts/forms/participant.py:75 +#: shiftings/shifts/forms/participant.py:75 msgid "One of the user fields is required!" msgstr "Eines der Benutzerfelder ist benötigt!" -#: src/shiftings/shifts/forms/participant.py:80 +#: shiftings/shifts/forms/participant.py:80 #, python-brace-format msgid "Cannot add {user} multiple times to this shift" msgstr "Benutzer {user} kann nicht mehrmals zur Schicht hinzugefügt werden" -#: src/shiftings/shifts/forms/permission.py:36 -#: src/shiftings/shifts/forms/permission.py:90 +#: shiftings/shifts/forms/permission.py:36 +#: shiftings/shifts/forms/permission.py:90 #, python-brace-format msgid "" "Permission would have no effect. Permission for all Users is more extensive: " @@ -2963,7 +1397,7 @@ msgstr "" "Berechtigung hätte keinen Effekt. Berechtigung für alle Benutzer ist " "weitergehender: {permission}" -#: src/shiftings/shifts/forms/permission.py:47 +#: shiftings/shifts/forms/permission.py:47 #, python-brace-format msgid "" "Permission would have no effect. There is an inherited more extensive " @@ -2972,7 +1406,7 @@ msgstr "" "Berechtigung hätte keinen Effekt. Es existiert eine weitergehende " "Berechtigung: {referred_object} ({permission})" -#: src/shiftings/shifts/forms/permission.py:56 +#: shiftings/shifts/forms/permission.py:56 #, python-brace-format msgid "" "Permission would have no effect. There is an inherited more extensive " @@ -2981,166 +1415,169 @@ msgstr "" "Berechtigung hätte keinen Effekt. Es existiert eine weitergehende " "Berechtigung für alle Benutzer: {referred_object} ({permission})" -#: src/shiftings/shifts/forms/permission.py:78 +#: shiftings/shifts/forms/permission.py:78 #, python-brace-format msgid "Can only set one permission for organization \"{organization}\"" msgstr "" "Es kann nur eine Berechtigung für die Organistation \"{organization}\" " "gesetzt werden" -#: src/shiftings/shifts/forms/permission.py:80 +#: shiftings/shifts/forms/permission.py:80 msgid "Can only set one permission for all Users" msgstr "Es kann nur eine Berechtigung für alle Benutzer gesetzt werden" -#: src/shiftings/shifts/forms/recurring.py:13 +#: shiftings/shifts/forms/recurring.py:13 msgid "Ordinal" msgstr "Ordinal" -#: src/shiftings/shifts/forms/recurring.py:35 +#: shiftings/shifts/forms/recurring.py:35 msgid "Weekday can't be empty with the chosen time frame type." msgstr "Der Wochentag ist erforderlich für den angegeben Zeitraumtyp." -#: src/shiftings/shifts/forms/recurring.py:37 +#: shiftings/shifts/forms/recurring.py:37 msgid "Month can't be empty with the chosen time frame type." msgstr "Der Monat ist erforderlich für den angegebenen Zeitraumtyp." -#: src/shiftings/shifts/forms/recurring.py:39 +#: shiftings/shifts/forms/recurring.py:39 msgid "You need to add a warning for weekend handling." msgstr "Du musst eine Warnung für die Wochenendproblembehandlung hinzufügen." -#: src/shiftings/shifts/forms/recurring.py:41 +#: shiftings/shifts/forms/recurring.py:41 msgid "You need to add a warning for holiday handling." msgstr "Du musst eine Warnung für die Feiertagsproblembehandlung hinzufügen." -#: src/shiftings/shifts/forms/shift.py:38 -#: src/shiftings/shifts/forms/template.py:66 +#: shiftings/shifts/forms/shift.py:41 +msgid "End time must be after start time" +msgstr "" + +#: shiftings/shifts/forms/shift.py:46 shiftings/shifts/forms/template.py:66 #, python-brace-format msgid "Shift is too long, can at most be {max} long" msgstr "Diese Schicht ist zu lang, sie kann Maximal {max} lang sein" -#: src/shiftings/shifts/forms/template.py:18 +#: shiftings/shifts/forms/template.py:18 msgid "Date" msgstr "Datum" -#: src/shiftings/shifts/forms/template.py:18 +#: shiftings/shifts/forms/template.py:18 msgid "Date to create the template on" msgstr "Datum an dem die Vorlage erstellt wird" -#: src/shiftings/shifts/forms/template.py:38 -#: src/shiftings/shifts/models/template.py:22 +#: shiftings/shifts/forms/template.py:38 shiftings/shifts/models/template.py:22 msgid "Start Delay" msgstr "Startverzögerung" -#: src/shiftings/shifts/forms/template.py:58 +#: shiftings/shifts/forms/template.py:40 shiftings/shifts/models/template.py:23 +#: shiftings/shifts/templates/shifts/recurring/list.html:11 +msgid "Duration" +msgstr "Dauer" + +#: shiftings/shifts/forms/template.py:58 msgid "Start Delay was None please reload and use the slider." msgstr "" "Anfangsverzögerung war None bitte lade die Seite neu und benutze den Slider." -#: src/shiftings/shifts/forms/template.py:64 +#: shiftings/shifts/forms/template.py:64 msgid "Duration was None please reload and use the slider." msgstr "Dauer war None bitte lade die Seite neu und benutze den Slider." -#: src/shiftings/shifts/forms/type.py:22 +#: shiftings/shifts/forms/type.py:22 msgid "Can't name a shift type \"System\"." msgstr "Ungültiger Name \"System\" für einen Schichttyp." -#: src/shiftings/shifts/models/base.py:13 -#: src/shiftings/shifts/models/template.py:19 -#: src/shiftings/shifts/templates/shifts/shift.html:39 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:18 +#: shiftings/shifts/models/base.py:13 shiftings/shifts/models/template.py:19 +#: shiftings/shifts/templates/shifts/shift.html:39 +#: shiftings/shifts/templates/shifts/template/template_form.html:18 msgid "Shift Type" msgstr "Schicht Typ" -#: src/shiftings/shifts/models/base.py:16 +#: shiftings/shifts/models/base.py:16 msgid "Required Users" msgstr "Benötigte Benutzer" -#: src/shiftings/shifts/models/base.py:18 -#: src/shiftings/shifts/models/template.py:27 +#: shiftings/shifts/models/base.py:18 shiftings/shifts/models/template.py:27 msgid "A maximum of 32 users can be required" msgstr "Es können maximal 32 Benutzer benötigt werden" -#: src/shiftings/shifts/models/base.py:19 +#: shiftings/shifts/models/base.py:19 msgid "Maximum Users" msgstr "Maximale Benutzer" -#: src/shiftings/shifts/models/base.py:21 -#: src/shiftings/shifts/models/template.py:30 +#: shiftings/shifts/models/base.py:21 shiftings/shifts/models/template.py:30 msgid "A maximum of 64 users can be present" msgstr "Es können maximal 64 Benutzer teilnehmen" -#: src/shiftings/shifts/models/base.py:23 -#: src/shiftings/shifts/models/template.py:32 -#: src/shiftings/shifts/templates/shifts/shift.html:79 -#: src/shiftings/shifts/templates/shifts/template/template.html:26 +#: shiftings/shifts/models/base.py:23 shiftings/shifts/models/template.py:32 +#: shiftings/shifts/templates/shifts/shift.html:79 +#: shiftings/shifts/templates/shifts/template/template.html:26 msgid "Additional Infos" msgstr "Zusätzliche Informationen" -#: src/shiftings/shifts/models/participant.py:16 +#: shiftings/shifts/models/participant.py:16 msgid "Display Name is optional, and will be shown instead of the username" msgstr "" "Der Anzeigename ist optional und wird anstatt dem Benutzernamen angezeigt" -#: src/shiftings/shifts/models/participant.py:17 +#: shiftings/shifts/models/participant.py:17 msgid "Participation confirmed" msgstr "Teilnahme bestätigt" -#: src/shiftings/shifts/models/participant.py:18 +#: shiftings/shifts/models/participant.py:18 msgid "" "Whether the participant has taken part in the shift or not. Default: False" msgstr "Hat der Teilnehmer die Schicht ausgeführt. Standard: False" -#: src/shiftings/shifts/models/permission.py:18 +#: shiftings/shifts/models/permission.py:18 msgid "No Permission" msgstr "Keine Berechtigung" -#: src/shiftings/shifts/models/permission.py:19 +#: shiftings/shifts/models/permission.py:19 msgid "See Shift exists" msgstr "Schichttexistenz sehen" -#: src/shiftings/shifts/models/permission.py:20 +#: shiftings/shifts/models/permission.py:20 msgid "See Shift Details" msgstr "Schichtdetails sehen" -#: src/shiftings/shifts/models/permission.py:21 +#: shiftings/shifts/models/permission.py:21 msgid "See Shift Participants" msgstr "Schichtteilnehmer sehen" -#: src/shiftings/shifts/models/permission.py:22 +#: shiftings/shifts/models/permission.py:22 msgid "Participate" msgstr "Teilnehmen" -#: src/shiftings/shifts/models/permission.py:80 +#: shiftings/shifts/models/permission.py:80 msgid "Permission Type" msgstr "Berechtigungstyp" -#: src/shiftings/shifts/models/permission.py:82 +#: shiftings/shifts/models/permission.py:82 msgid "Affected Organization" msgstr "Betroffene Organisationen" -#: src/shiftings/shifts/models/recurring.py:26 +#: shiftings/shifts/models/recurring.py:26 msgid "ignore" msgstr "ignorieren" -#: src/shiftings/shifts/models/recurring.py:27 +#: shiftings/shifts/models/recurring.py:27 msgid "cancel" msgstr "abbrechen" -#: src/shiftings/shifts/models/recurring.py:28 +#: shiftings/shifts/models/recurring.py:28 msgid "show warning" msgstr "Warnung anzeigen" -#: src/shiftings/shifts/models/recurring.py:36 +#: shiftings/shifts/models/recurring.py:36 msgid "Timeframe Type" msgstr "Zeitraum Typ" -#: src/shiftings/shifts/models/recurring.py:41 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:10 -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:53 +#: shiftings/shifts/models/recurring.py:41 +#: shiftings/shifts/templates/shifts/recurring/list.html:10 +#: shiftings/shifts/templates/shifts/recurring/shift.html:53 msgid "First Occurrence" msgstr "Erster Termin" -#: src/shiftings/shifts/models/recurring.py:42 +#: shiftings/shifts/models/recurring.py:42 msgid "" "Choose a minimum day for first occurrence and the system will automatically " "choose the next applicable day." @@ -3148,142 +1585,142 @@ msgstr "" "Wähle einen Tag für die erste Angabe und das system wird automatisch den " "nächsten zutreffenden Tag berechnen." -#: src/shiftings/shifts/models/recurring.py:46 +#: shiftings/shifts/models/recurring.py:46 msgid "Auto create days" msgstr "" -#: src/shiftings/shifts/models/recurring.py:46 +#: shiftings/shifts/models/recurring.py:46 msgid "How many days in advance should the shifts be created?" msgstr "" -#: src/shiftings/shifts/models/recurring.py:48 +#: shiftings/shifts/models/recurring.py:48 msgid "Shift Template" msgstr "Schichtvorlage" -#: src/shiftings/shifts/models/recurring.py:52 +#: shiftings/shifts/models/recurring.py:52 msgid "Weekend problem handling" msgstr "Wochenendproblembehandlung" -#: src/shiftings/shifts/models/recurring.py:54 +#: shiftings/shifts/models/recurring.py:54 msgid "Warning text for weekend" msgstr "Warnung für Wochenenden" -#: src/shiftings/shifts/models/recurring.py:59 +#: shiftings/shifts/models/recurring.py:59 msgid "Holidays problem handling" msgstr "Feiertagsproblembehandlung" -#: src/shiftings/shifts/models/recurring.py:61 +#: shiftings/shifts/models/recurring.py:61 msgid "Warning text for holidays" msgstr "Warnung für Feiertage" -#: src/shiftings/shifts/models/recurring.py:67 +#: shiftings/shifts/models/recurring.py:67 msgid "Manually Disabled" msgstr "Manuell deaktiviert" -#: src/shiftings/shifts/models/recurring.py:88 +#: shiftings/shifts/models/recurring.py:88 msgid "Nth" msgstr "Nte" -#: src/shiftings/shifts/models/recurring.py:89 +#: shiftings/shifts/models/recurring.py:89 msgid "[weekday]" msgstr "[Wochentag]" -#: src/shiftings/shifts/models/recurring.py:90 +#: shiftings/shifts/models/recurring.py:90 msgid "[month]" msgstr "[Monat]" -#: src/shiftings/shifts/models/shift.py:24 +#: shiftings/shifts/models/shift.py:24 msgid "Start Date and Time" msgstr "Startdatum und Zeit" -#: src/shiftings/shifts/models/shift.py:25 +#: shiftings/shifts/models/shift.py:25 msgid "End Date and Time" msgstr "Enddatum und Zeit" -#: src/shiftings/shifts/models/shift.py:27 -#: src/shiftings/shifts/templates/shifts/template/shift.html:13 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:21 +#: shiftings/shifts/models/shift.py:27 +#: shiftings/shifts/templates/shifts/template/shift.html:13 +#: shiftings/shifts/templates/shifts/template/template_form.html:21 msgid "Users" msgstr "Benutzer" -#: src/shiftings/shifts/models/shift.py:30 +#: shiftings/shifts/models/shift.py:30 msgid "Locked for Participation" msgstr "Teilnahme geschlossen" -#: src/shiftings/shifts/models/shift.py:31 -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:58 -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:64 +#: shiftings/shifts/models/shift.py:31 +#: shiftings/shifts/templates/shifts/recurring/shift.html:58 +#: shiftings/shifts/templates/shifts/recurring/shift.html:64 msgid "Warning" msgstr "Warnung" -#: src/shiftings/shifts/models/shift.py:35 +#: shiftings/shifts/models/shift.py:35 msgid "Created by Recurring Shift" msgstr "Erstellt von wiederkehrender Schicht" -#: src/shiftings/shifts/models/shift.py:37 -#: src/shiftings/shifts/templates/shifts/shift.html:53 +#: shiftings/shifts/models/shift.py:37 +#: shiftings/shifts/templates/shifts/shift.html:53 msgid "Created" msgstr "Erstellt" -#: src/shiftings/shifts/models/shift.py:38 -#: src/shiftings/shifts/templates/shifts/shift.html:55 +#: shiftings/shifts/models/shift.py:38 +#: shiftings/shifts/templates/shifts/shift.html:55 msgid "Last Modified" msgstr "Zuletzt geändert" -#: src/shiftings/shifts/models/shift.py:54 +#: shiftings/shifts/models/shift.py:54 msgid "Organization and Event Organization must be identical." msgstr "Organistion und Event Organistation müssen identisch sein." -#: src/shiftings/shifts/models/shift.py:57 +#: shiftings/shifts/models/shift.py:57 #, python-brace-format msgid "Shift {name} on {time}" msgstr "Schicht {name} am {time}" -#: src/shiftings/shifts/models/shift.py:65 +#: shiftings/shifts/models/shift.py:65 #, python-brace-format msgid "{name} from {start} to {end} " msgstr "{name} von {start} bis {end} " -#: src/shiftings/shifts/models/shift.py:72 +#: shiftings/shifts/models/shift.py:72 #, python-brace-format msgid "{start} to {end_time} on {end_date} " msgstr "{start} bis {end_time} am {end_date} " -#: src/shiftings/shifts/models/shift.py:76 +#: shiftings/shifts/models/shift.py:76 #, python-brace-format msgid "{start} to {end_time}" msgstr "{start} bis {end_time}" -#: src/shiftings/shifts/models/summary.py:11 +#: shiftings/shifts/models/summary.py:11 msgid "\"Other\" Shift Type Group Name" msgstr "\"Sonstige\" Schichttyp Gruppenname" -#: src/shiftings/shifts/models/summary.py:14 +#: shiftings/shifts/models/summary.py:14 msgid "Default time range for summary" msgstr "Standard Zeitraum für die Zusammenfassung" -#: src/shiftings/shifts/models/summary.py:25 +#: shiftings/shifts/models/summary.py:25 #, python-brace-format msgid "Organization summary settings of {organization}" msgstr "Zusammenfassungseinstellungen von {organization}" -#: src/shiftings/shifts/models/template.py:16 +#: shiftings/shifts/models/template.py:16 msgid "Template Group" msgstr "Vorlagengruppe" -#: src/shiftings/shifts/models/template.py:25 +#: shiftings/shifts/models/template.py:25 msgid "Required User" msgstr "Benötigte Benutzer" -#: src/shiftings/shifts/models/template.py:28 +#: shiftings/shifts/models/template.py:28 msgid "Maximum User" msgstr "Maximale Benutzer" -#: src/shiftings/shifts/signals.py:26 +#: shiftings/shifts/signals.py:26 msgid "Could not find a valid first occurrence." msgstr "Konnte keinen validen ersten Termin finden." -#: src/shiftings/shifts/templates/shifts/create_shift.html:11 +#: shiftings/shifts/templates/shifts/create_shift.html:11 #, python-format msgid "" "\n" @@ -3294,7 +1731,7 @@ msgstr "" "

Neue Schicht für %(organization)s erstellen

\n" " " -#: src/shiftings/shifts/templates/shifts/create_shift.html:15 +#: shiftings/shifts/templates/shifts/create_shift.html:15 #, python-format msgid "" "\n" @@ -3306,70 +1743,70 @@ msgstr "" "h4>\n" " " -#: src/shiftings/shifts/templates/shifts/create_shift.html:22 -#: src/shiftings/shifts/templates/shifts/create_shift.html:83 -#: src/shiftings/shifts/templates/shifts/recurring/form.html:23 -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:19 -#: src/shiftings/templates/generic/create_or_update.html:18 -#: src/shiftings/templates/generic/form_card.html:8 +#: shiftings/shifts/templates/shifts/create_shift.html:22 +#: shiftings/shifts/templates/shifts/create_shift.html:93 +#: shiftings/shifts/templates/shifts/recurring/form.html:23 +#: shiftings/shifts/templates/shifts/recurring/shift.html:19 +#: shiftings/templates/generic/create_or_update.html:18 +#: shiftings/templates/generic/form_card.html:8 msgid "Create" msgstr "Erstellen" -#: src/shiftings/shifts/templates/shifts/create_shift.html:24 +#: shiftings/shifts/templates/shifts/create_shift.html:24 msgid "Update" msgstr "Aktualisieren" -#: src/shiftings/shifts/templates/shifts/create_shift.html:26 -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:34 -#: src/shiftings/templates/generic/create_or_update.html:22 +#: shiftings/shifts/templates/shifts/create_shift.html:26 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:34 +#: shiftings/templates/generic/create_or_update.html:22 msgid "Back" msgstr "Zurück" -#: src/shiftings/shifts/templates/shifts/create_shift.html:47 +#: shiftings/shifts/templates/shifts/create_shift.html:57 msgid "Shift starting time" msgstr "Schicht Startzeit" -#: src/shiftings/shifts/templates/shifts/create_shift.html:48 +#: shiftings/shifts/templates/shifts/create_shift.html:58 msgctxt "Shift time" msgid "Start Time" msgstr "Startzeit" -#: src/shiftings/shifts/templates/shifts/create_shift.html:52 +#: shiftings/shifts/templates/shifts/create_shift.html:62 msgid "Shift end time" msgstr "Schichtende" -#: src/shiftings/shifts/templates/shifts/create_shift.html:53 +#: shiftings/shifts/templates/shifts/create_shift.html:63 msgctxt "Shift time" msgid "End Time" msgstr "Endzeit" -#: src/shiftings/shifts/templates/shifts/create_shift.html:59 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:24 +#: shiftings/shifts/templates/shifts/create_shift.html:69 +#: shiftings/shifts/templates/shifts/template/template_form.html:24 msgid "Required number of users to fill this shift" msgstr "Benötigte Anzahl an Benutzern um die Schicht zu füllen" -#: src/shiftings/shifts/templates/shifts/create_shift.html:60 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:25 +#: shiftings/shifts/templates/shifts/create_shift.html:70 +#: shiftings/shifts/templates/shifts/template/template_form.html:25 msgctxt "Shift users" msgid "Required" msgstr "Benötigt" -#: src/shiftings/shifts/templates/shifts/create_shift.html:64 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:29 +#: shiftings/shifts/templates/shifts/create_shift.html:74 +#: shiftings/shifts/templates/shifts/template/template_form.html:29 msgid "Maximum number of users allowed in this shift" msgstr "Maximale Anzahl an Benutzern in dieser Schicht" -#: src/shiftings/shifts/templates/shifts/create_shift.html:65 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:30 +#: shiftings/shifts/templates/shifts/create_shift.html:75 +#: shiftings/shifts/templates/shifts/template/template_form.html:30 msgctxt "Shift users" msgid "Maximum" msgstr "Maximum" -#: src/shiftings/shifts/templates/shifts/create_shift.html:76 +#: shiftings/shifts/templates/shifts/create_shift.html:86 msgid "Create from Template" msgstr "Aus Vorlage erstellen" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:17 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:17 #, python-format msgid "" "\n" @@ -3382,39 +1819,39 @@ msgstr "" "mit \"+\" hinzu\n" " " -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:26 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:26 msgid "Edit Shift Permissions for" msgstr "Schichtberechtigungen bearbeiten" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:38 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:38 msgid "How does it work?" msgstr "Wie funktioniert es?" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:53 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:53 msgid "Inherited Permissions" msgstr "Geerbte Berechtigungen" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:65 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:65 msgid "All Users" msgstr "Alle Benutzer" -#: src/shiftings/shifts/templates/shifts/edit_templates.html:21 +#: shiftings/shifts/templates/shifts/edit_templates.html:21 msgid "Edit Templates for" msgstr "Bearbeite Vorlagen für" -#: src/shiftings/shifts/templates/shifts/edit_templates.html:29 +#: shiftings/shifts/templates/shifts/edit_templates.html:29 msgid "Cancel" msgstr "Abbrechen" -#: src/shiftings/shifts/templates/shifts/recurring/form.html:33 +#: shiftings/shifts/templates/shifts/recurring/form.html:33 msgid "Recurring Time Frame" msgstr "Wiederkehrender Zeitraum" -#: src/shiftings/shifts/templates/shifts/recurring/form.html:42 +#: shiftings/shifts/templates/shifts/recurring/form.html:42 msgid "Shift Information" msgstr "Schichtinformation" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:16 +#: shiftings/shifts/templates/shifts/recurring/shift.html:16 #, python-format msgid "" "\n" @@ -3425,11 +1862,11 @@ msgstr "" " Möchtest du diese Schicht am %(date)s erstellen\n" " " -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:37 +#: shiftings/shifts/templates/shifts/recurring/shift.html:37 msgid "Edit Recurring Shift" msgstr "Wiederkehrende Schicht bearbeiten" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:44 +#: shiftings/shifts/templates/shifts/recurring/shift.html:44 #, python-format msgid "" "\n" @@ -3442,7 +1879,7 @@ msgstr "" "%(time)s\n" " " -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:48 +#: shiftings/shifts/templates/shifts/recurring/shift.html:48 #, fuzzy, python-format #| msgid "" #| "\n" @@ -3457,44 +1894,44 @@ msgstr "" " vor %(time)s\n" " " -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:55 +#: shiftings/shifts/templates/shifts/recurring/shift.html:55 msgid "Weekend Handling" msgstr "Wochenendvorgehen" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:61 +#: shiftings/shifts/templates/shifts/recurring/shift.html:61 msgid "Holiday Handling" msgstr "Feiertagsvorgehen" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:75 +#: shiftings/shifts/templates/shifts/recurring/shift.html:75 msgid "Upcoming recurring Shifts" msgstr "Anstehende wiederkehrende Schichten" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:86 +#: shiftings/shifts/templates/shifts/recurring/shift.html:86 msgid "No upcoming created Shifts" msgstr "Keine anstehenden erstellten Schichten" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:93 +#: shiftings/shifts/templates/shifts/recurring/shift.html:93 msgid "Passed recurring Shifts" msgstr "Vergangenen Wiederkehrende Schichten" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:104 +#: shiftings/shifts/templates/shifts/recurring/shift.html:104 msgid "No created Shifts have passed yet" msgstr "Keine erstellten Schichten sind abgelaufen" -#: src/shiftings/shifts/templates/shifts/shift.html:16 -#: src/shiftings/shifts/views/shift.py:78 +#: shiftings/shifts/templates/shifts/shift.html:16 +#: shiftings/shifts/views/shift.py:78 msgid "Edit Shift" msgstr "Schicht bearbeiten" -#: src/shiftings/shifts/templates/shifts/shift.html:22 +#: shiftings/shifts/templates/shifts/shift.html:22 msgid "Delete Shift" msgstr "Schicht löschen" -#: src/shiftings/shifts/templates/shifts/shift.html:28 +#: shiftings/shifts/templates/shifts/shift.html:28 msgid "Edit Permissions" msgstr "Berechtigungen bearbeiten" -#: src/shiftings/shifts/templates/shifts/shift.html:57 +#: shiftings/shifts/templates/shifts/shift.html:57 #, python-format msgid "" "\n" @@ -3505,219 +1942,219 @@ msgstr "" " vor %(time)s\n" " " -#: src/shiftings/shifts/templates/shifts/shift.html:66 -#: src/shiftings/shifts/templates/shifts/template/shift_card.html:13 +#: shiftings/shifts/templates/shifts/shift.html:66 +#: shiftings/shifts/templates/shifts/template/shift_card.html:13 msgid "Shift Participants" msgstr "Schichtteilnehmer" -#: src/shiftings/shifts/templates/shifts/shift.html:71 +#: shiftings/shifts/templates/shifts/shift.html:71 msgid "Add participant" msgstr "Teilnehmer hinzufügen" -#: src/shiftings/shifts/templates/shifts/shift.html:84 +#: shiftings/shifts/templates/shifts/shift.html:84 msgid "Nothing to consider." msgstr "Nichts zu beachten." -#: src/shiftings/shifts/templates/shifts/shift_participants.html:11 +#: shiftings/shifts/templates/shifts/shift_participants.html:11 msgid "" "This shift is over, do you really want/need to add yourself as participant?" msgstr "" -#: src/shiftings/shifts/templates/shifts/shift_url_filters.html:8 +#: shiftings/shifts/templates/shifts/shift_url_filters.html:8 msgid "Toggle Shift Filters" msgstr "Schichtfilter umschalten" -#: src/shiftings/shifts/templates/shifts/summary/summary.html:6 +#: shiftings/shifts/templates/shifts/summary/summary.html:6 msgid "Shift Summary" msgstr "Mitglieder Schichten Zusammenfassung" -#: src/shiftings/shifts/templates/shifts/template/group.html:17 +#: shiftings/shifts/templates/shifts/template/group.html:17 msgid "Delete Template" msgstr "Vorlage löschen" -#: src/shiftings/shifts/templates/shifts/template/group.html:21 +#: shiftings/shifts/templates/shifts/template/group.html:21 msgid "Edit Template" msgstr "Vorlage bearbeiten" -#: src/shiftings/shifts/templates/shifts/template/group.html:25 -#: src/shiftings/shifts/templates/shifts/template/group_template.html:13 +#: shiftings/shifts/templates/shifts/template/group.html:25 +#: shiftings/shifts/templates/shifts/template/group_template.html:13 msgid "Edit Shifts" msgstr "Schichten bearbeiten" -#: src/shiftings/shifts/templates/shifts/template/group.html:31 +#: shiftings/shifts/templates/shifts/template/group.html:31 msgid "Edit Template Permissions" msgstr "Vorlagenberechtigungen bearbeiten" -#: src/shiftings/shifts/templates/shifts/template/group.html:70 +#: shiftings/shifts/templates/shifts/template/group.html:70 msgid "Create Shift Templates" msgstr "Schichtvorlagen erstellen" -#: src/shiftings/shifts/templates/shifts/template/group_template.html:9 +#: shiftings/shifts/templates/shifts/template/group_template.html:9 msgid "Edit Shifts Template" msgstr "Schichtvorlage bearbeiten" -#: src/shiftings/shifts/templates/shifts/template/member_shift_summary.html:3 +#: shiftings/shifts/templates/shifts/template/member_shift_summary.html:3 msgid "Member" msgstr "Mitglied" -#: src/shiftings/shifts/templates/shifts/template/member_shift_summary.html:10 +#: shiftings/shifts/templates/shifts/template/member_shift_summary.html:10 msgid "Total" msgstr "Summe" -#: src/shiftings/shifts/templates/shifts/template/participation_permission_form.html:9 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:10 +#: shiftings/shifts/templates/shifts/template/participation_permission_form.html:9 +#: shiftings/shifts/templates/shifts/template/template_form.html:10 msgid "Remove Shift" msgstr "Schicht löschen" -#: src/shiftings/shifts/templates/shifts/template/participation_permission_form.html:12 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:13 +#: shiftings/shifts/templates/shifts/template/participation_permission_form.html:12 +#: shiftings/shifts/templates/shifts/template/template_form.html:13 msgid "Restore Shift" msgstr "Schicht wiederherstellen" -#: src/shiftings/shifts/templates/shifts/template/select_org.html:3 +#: shiftings/shifts/templates/shifts/template/select_org.html:3 msgid "Select Organization to create Shift for" msgstr "Organisation auswählen für die eine Schicht erstellt werden soll" -#: src/shiftings/shifts/templates/shifts/template/shift.html:5 +#: shiftings/shifts/templates/shifts/template/shift.html:5 msgid "Go to Shift" msgstr "Zur Schicht springen" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:5 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:5 msgid "Filters" msgstr "Filter" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:8 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:8 msgid "Reset Filter" msgstr "Filter zurürcksetzen" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:12 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:12 msgid "Apply Filter" msgstr "Filter anwenden" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:15 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:15 msgid "Parameters" msgstr "Parameter" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:29 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:29 msgid "Select Organizations" msgstr "Wähle Organisationen" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:47 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:47 msgid "Shift starts after" msgstr "Schicht startet nach" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:52 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:52 msgid "Shift ends before" msgstr "Schicht endet vor" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:65 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:65 msgid "Select Events" msgstr "Events auswählen" -#: src/shiftings/shifts/templates/shifts/template/slots_display.html:4 +#: shiftings/shifts/templates/shifts/template/slots_display.html:4 msgid "Required to carry out this Shift" msgstr "Benötigte um die Schicht zu erledigen" -#: src/shiftings/shifts/templates/shifts/template/slots_display.html:6 +#: shiftings/shifts/templates/shifts/template/slots_display.html:6 msgid "Open Shift Slot" msgstr "Offene Postiion" -#: src/shiftings/shifts/templates/shifts/template/slots_display.html:32 +#: shiftings/shifts/templates/shifts/template/slots_display.html:32 msgid "Free" msgstr "Frei" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:20 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:20 msgid "Required" msgstr "Benötigt" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:23 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:23 msgid "Confirmed" msgstr "Bestätigt" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:29 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:29 msgid "Participating" msgstr "Teilnehmer" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:34 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:34 msgid "Full" msgstr "Voll" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:40 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:40 msgid "Additional Users required" msgstr "Zusätzliche Mitglieder benötigt" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:42 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:42 msgid "Additional Slots available" msgstr "Zusätzliche Plätze verfügbar" -#: src/shiftings/shifts/templates/shifts/template/template.html:11 +#: shiftings/shifts/templates/shifts/template/template.html:11 msgid "Calendar Background Color" msgstr "Kalender Hintergrundfarbe" -#: src/shiftings/shifts/templates/shifts/template/template.html:17 +#: shiftings/shifts/templates/shifts/template/template.html:17 msgid "Required Users/Max Users" msgstr "Benötigte Benutzer/Maximale Benutzer" -#: src/shiftings/shifts/templates/shifts/template/template.html:21 +#: shiftings/shifts/templates/shifts/template/template.html:21 msgid "Unlimited" msgstr "Unbegrenzt" -#: src/shiftings/shifts/templates/shifts/template/template_form.html:36 +#: shiftings/shifts/templates/shifts/template/template_form.html:36 msgid "Delay to starting time" msgstr "Verzögerung zur Startzeit" -#: src/shiftings/shifts/templates/shifts/template/template_form.html:37 +#: shiftings/shifts/templates/shifts/template/template_form.html:37 msgctxt "Shift time" msgid "Start Delay" msgstr "Startverzögerung" -#: src/shiftings/shifts/templates/shifts/template/template_form.html:41 +#: shiftings/shifts/templates/shifts/template/template_form.html:41 msgid "Shift Duration" msgstr "Dauer der Schicht" -#: src/shiftings/shifts/templates/shifts/template/template_form.html:42 +#: shiftings/shifts/templates/shifts/template/template_form.html:42 msgctxt "Shift time" msgid "Duration" msgstr "Dauer" -#: src/shiftings/shifts/templates/shifts/type_group/list.html:8 +#: shiftings/shifts/templates/shifts/type_group/list.html:8 msgid "Actions" msgstr "Aktionen" -#: src/shiftings/shifts/templatetags/shifts.py:90 +#: shiftings/shifts/templatetags/shifts.py:90 #, python-brace-format msgid "Days + {delta_days}" msgstr "Tage + {delta_days}" -#: src/shiftings/shifts/utils/time_frame.py:17 +#: shiftings/shifts/utils/time_frame.py:17 msgid "Nth [weekday] of each month" msgstr "Nte [Wochentag] jeden Monat" -#: src/shiftings/shifts/utils/time_frame.py:18 +#: shiftings/shifts/utils/time_frame.py:18 msgid "Nth day of each month" msgstr "Nte Tag jeden Monat" -#: src/shiftings/shifts/utils/time_frame.py:19 +#: shiftings/shifts/utils/time_frame.py:19 msgid "every Nth [weekday]" msgstr "Jeden Nte [Wochentag]" -#: src/shiftings/shifts/utils/time_frame.py:20 +#: shiftings/shifts/utils/time_frame.py:20 msgid "Nth workday of each month" msgstr "Jeden Nte Werktag jedes Monats" -#: src/shiftings/shifts/utils/time_frame.py:21 +#: shiftings/shifts/utils/time_frame.py:21 msgid "Nth day of [month]" msgstr "Nte Tag des [Monats]" -#: src/shiftings/shifts/utils/time_frame.py:22 +#: shiftings/shifts/utils/time_frame.py:22 msgid "Nth workday of [month]" msgstr "Nte Werktag des [Monats]" -#: src/shiftings/shifts/views/participant.py:42 +#: shiftings/shifts/views/participant.py:42 msgid "This shift is over, do you really want/need to add a participant?" msgstr "" -#: src/shiftings/shifts/views/permission.py:50 +#: shiftings/shifts/views/permission.py:50 #, python-brace-format msgid "" "

Any Permission present will be applied to all Shifts belonging to " @@ -3732,35 +2169,36 @@ msgstr "" "weitergehenden Berechtigungen. Berechtigungen können für \"Alle Benutzer " "oder spezielle Organisationen festgelegt werden.\"

" -#: src/shiftings/shifts/views/recurring.py:85 +#: shiftings/shifts/views/recurring.py:85 msgid "Forbidden Method GET" msgstr "Verbotene Methode GET" -#: src/shiftings/shifts/views/recurring.py:98 +#: shiftings/shifts/views/recurring.py:98 +#, python-brace-format msgid "Created Shifts on {}" msgstr "Schicht am {} erstellt" -#: src/shiftings/shifts/views/recurring.py:102 +#: shiftings/shifts/views/recurring.py:102 msgid "Error while creating shifts. Invalid Date" msgstr "Fehler beim erstellen der Schichten. Ungültiges Datum" -#: src/shiftings/shifts/views/shift.py:164 +#: shiftings/shifts/views/shift.py:164 msgid "Unable to delete past shifts." msgstr "Konnte vergangene Schichten nicht löschen." -#: src/shiftings/templates/403.html:6 +#: shiftings/templates/403.html:6 msgid "You don't have the permission to do that!" msgstr "Du besitzt nicht die benötigten Berechtigungen!" -#: src/shiftings/templates/404.html:6 +#: shiftings/templates/404.html:6 msgid "The site you requested was not found." msgstr "Die gesuchte Seite wurde nicht gefunden." -#: src/shiftings/templates/500.html:6 +#: shiftings/templates/500.html:6 msgid "A server error occured the admins have been notified" msgstr "Ein Fehler ist aufgereten die Admins wurden benachrichtigt" -#: src/shiftings/templates/generic/create_or_update.html:5 +#: shiftings/templates/generic/create_or_update.html:5 #, python-format msgid "" "\n" @@ -3771,7 +2209,7 @@ msgstr "" "

Erstelle %(object)s

\n" " " -#: src/shiftings/templates/generic/create_or_update.html:9 +#: shiftings/templates/generic/create_or_update.html:9 #, python-format msgid "" "\n" @@ -3782,7 +2220,7 @@ msgstr "" "

Aktualisiere %(object_name)s

\n" " " -#: src/shiftings/templates/generic/delete.html:6 +#: shiftings/templates/generic/delete.html:6 #, python-format msgid "" "\n" @@ -3794,53 +2232,66 @@ msgstr "" "p>\n" " " -#: src/shiftings/templates/generic/modular_search.html:4 +#: shiftings/templates/generic/delete.html:9 +msgid "Yes" +msgstr "Ja" + +#: shiftings/templates/generic/delete.html:11 +msgid "No" +msgstr "Nein" + +#: shiftings/templates/generic/modular_search.html:4 msgid "Filter by Name" msgstr "Nach Namen filtern" -#: src/shiftings/templates/generic/modular_search.html:6 +#: shiftings/templates/generic/modular_search.html:6 msgid "submit search" msgstr "Suche starten" -#: src/shiftings/templates/language.html:16 +#: shiftings/templates/language.html:16 msgid "Language" msgstr "Sprache" -#: src/shiftings/templates/language.html:21 +#: shiftings/templates/language.html:21 msgid "German" msgstr "Deutsch" -#: src/shiftings/templates/language.html:24 +#: shiftings/templates/language.html:24 msgid "English" msgstr "Englisch" -#: src/shiftings/templates/month_view.html:2 +#: shiftings/templates/local/gdpr_template.sample.html:29 +#: shiftings/templates/template/remove_modal.html:39 +#: shiftings/templates/template/simple_display_modal.html:15 +#: shiftings/templates/template/simple_form_modal.html:22 +msgid "Close" +msgstr "Schließen" + +#: shiftings/templates/month_view.html:2 msgid "M" msgstr "M" -#: src/shiftings/templates/month_view.html:3 -#: src/shiftings/templates/month_view.html:5 +#: shiftings/templates/month_view.html:3 shiftings/templates/month_view.html:5 msgid "T" msgstr "T" -#: src/shiftings/templates/month_view.html:4 +#: shiftings/templates/month_view.html:4 msgid "W" msgstr "W" -#: src/shiftings/templates/month_view.html:6 +#: shiftings/templates/month_view.html:6 msgid "F" msgstr "F" -#: src/shiftings/templates/month_view.html:7 -#: src/shiftings/templates/month_view.html:8 +#: shiftings/templates/month_view.html:7 shiftings/templates/month_view.html:8 msgid "S" msgstr "S" -#: src/shiftings/templates/switch_user.html:5 +#: shiftings/templates/switch_user.html:5 msgid "Switch User" msgstr "Benutzer wechseln" -#: src/shiftings/templates/template/remove_modal.html:12 +#: shiftings/templates/template/remove_modal.html:12 #, python-format msgid "" "\n" @@ -3853,7 +2304,7 @@ msgstr "" "bold\"> von %(target)s\n" " " -#: src/shiftings/templates/template/remove_modal.html:16 +#: shiftings/templates/template/remove_modal.html:16 msgid "" "\n" " Removing \n" " " -#: src/shiftings/templates/template/remove_modal.html:26 +#: shiftings/templates/template/remove_modal.html:26 #, python-format msgid "" "\n" @@ -3880,7 +2331,7 @@ msgstr "" "text-danger\">\" von %(target)s\n" " " -#: src/shiftings/templates/template/remove_modal.html:31 +#: shiftings/templates/template/remove_modal.html:31 msgid "" "\n" " Are you sure you want to remove\n" @@ -3894,101 +2345,404 @@ msgstr "" "text-danger\"> löschen möchtest\"\n" " " -#: src/shiftings/templates/template/remove_modal.html:38 +#: shiftings/templates/template/remove_modal.html:38 msgid "Remove" msgstr "Entfernen" -#: src/shiftings/templates/template/remove_modal.html:39 -#: src/shiftings/templates/template/simple_display_modal.html:15 -#: src/shiftings/templates/template/simple_form_modal.html:22 -msgid "Close" -msgstr "Schließen" +#: shiftings/templates/theme.html:23 +msgid "Theme" +msgstr "" -#: src/shiftings/templates/top_nav.html:12 +#: shiftings/templates/top_nav.html:12 msgid "Overview" msgstr "Übersicht" -#: src/shiftings/templates/top_nav.html:32 -#: src/shiftings/templates/top_nav.html:33 +#: shiftings/templates/top_nav.html:32 shiftings/templates/top_nav.html:33 msgid "Help" msgstr "Hilfe" -#: src/shiftings/templates/top_nav.html:48 +#: shiftings/templates/top_nav.html:49 msgid "Profile" msgstr "Profil" -#: src/shiftings/templates/widgets/time_slider.html:9 +#: shiftings/templates/widgets/time_slider.html:9 msgid "You need to enter a valid timeframe in the form of HH:MM." msgstr "Du muss einen validen Zeitraum inder Form HH:MM eingeben." -#: src/shiftings/templates/widgets/time_slider.html:13 +#: shiftings/templates/widgets/time_slider.html:13 msgid "Day: +" msgstr "Tag: +" -#: src/shiftings/utils/string.py:5 +#: shiftings/utils/string.py:5 #, python-format msgid "The function %(name)s has to be implemented." msgstr "Die Funktion %(name)s muss implementiert werden." -#: src/shiftings/utils/time/month.py:32 +#: shiftings/utils/time/month.py:10 +msgid "January" +msgstr "Januar" + +#: shiftings/utils/time/month.py:11 +msgid "February" +msgstr "Februar" + +#: shiftings/utils/time/month.py:12 +msgid "March" +msgstr "März" + +#: shiftings/utils/time/month.py:13 +msgid "April" +msgstr "April" + +#: shiftings/utils/time/month.py:14 +msgid "May" +msgstr "Mai" + +#: shiftings/utils/time/month.py:15 +msgid "June" +msgstr "Juni" + +#: shiftings/utils/time/month.py:16 +msgid "July" +msgstr "Juli" + +#: shiftings/utils/time/month.py:17 +msgid "August" +msgstr "August" + +#: shiftings/utils/time/month.py:18 +msgid "September" +msgstr "September" + +#: shiftings/utils/time/month.py:19 +msgid "October" +msgstr "Oktober" + +#: shiftings/utils/time/month.py:20 +msgid "November" +msgstr "November" + +#: shiftings/utils/time/month.py:21 +msgid "December" +msgstr "Dezember" + +#: shiftings/utils/time/month.py:32 msgid "Day of the Month" msgstr "Tag des Monats" -#: src/shiftings/utils/time/timerange.py:13 +#: shiftings/utils/time/timerange.py:13 msgid "Quarter" msgstr "Quartal" -#: src/shiftings/utils/time/timerange.py:14 +#: shiftings/utils/time/timerange.py:14 msgid "Half Year" msgstr "Halbjahr" -#: src/shiftings/utils/time/timerange.py:15 +#: shiftings/utils/time/timerange.py:15 msgid "Year" msgstr "Jahr" -#: src/shiftings/utils/time/timerange.py:16 +#: shiftings/utils/time/timerange.py:16 msgid "Decade" msgstr "Dekade" -#: src/shiftings/utils/time/timerange.py:17 +#: shiftings/utils/time/timerange.py:17 msgid "Century" msgstr "Jahrhundert" -#: src/shiftings/utils/time/timerange.py:18 +#: shiftings/utils/time/timerange.py:18 msgid "Millennium" msgstr "Millennium" -#: src/shiftings/utils/time/week.py:27 +#: shiftings/utils/time/week.py:13 +msgid "Monday" +msgstr "Montag" + +#: shiftings/utils/time/week.py:14 +msgid "Tuesday" +msgstr "Dienstag" + +#: shiftings/utils/time/week.py:15 +msgid "Wednesday" +msgstr "Mittwoch" + +#: shiftings/utils/time/week.py:16 +msgid "Thursday" +msgstr "Donnerstag" + +#: shiftings/utils/time/week.py:17 +msgid "Friday" +msgstr "Freitag" + +#: shiftings/utils/time/week.py:18 +msgid "Saturday" +msgstr "Samstag" + +#: shiftings/utils/time/week.py:19 +msgid "Sunday" +msgstr "Sonntag" + +#: shiftings/utils/time/week.py:27 msgid "Day of the Week" msgstr "Wochentag" -#: src/shiftings/utils/typing.py:20 +#: shiftings/utils/typing.py:20 msgid "Value is not supposed to be None." msgstr "Dieser Wert sollte nicht None sein." -#: src/shiftings/utils/views/base.py:62 +#: shiftings/utils/views/base.py:62 msgid "The pk is missing from the url. This is not supposed to be possible." msgstr "Der PK fehlt in der url. Das sollte nicht passieren." -#: src/shiftings/utils/views/base.py:65 +#: shiftings/utils/views/base.py:65 #, python-format msgid "There is no %(name)s with that pk." msgstr "Es existiert kein %(name)s mit diesem pk." -#: src/shiftings/utils/views/base.py:73 +#: shiftings/utils/views/base.py:73 msgid "You don't have the required permission." msgstr "Du besitzt nicht die benötigten Berechtigungen." -#: src/shiftings/utils/views/base.py:90 +#: shiftings/utils/views/base.py:90 msgid "No URL to redirect to. Provide a success_url." msgstr "Keine URL zum verweisen. Bitte gib eine success_url an." -#: src/shiftings/utils/views/base.py:108 +#: shiftings/utils/views/base.py:108 msgid "Could not find the requested page. This might be a configuration error." msgstr "" "Konnte die angefragte Seite nicht finden. Dies könnte ein Einstellungsfehler " "sein." +#~ msgid "Include Protocol i.E. https://example.com" +#~ msgstr "Inklusive Protokoll z.B. https://example.com" + +#, fuzzy +#~| msgid "Abort" +#~ msgid "Aborted!" +#~ msgstr "Abbrechen" + +#, fuzzy +#~| msgid "Actions" +#~ msgid "Options" +#~ msgstr "Aktionen" + +#, fuzzy +#~| msgid "Required" +#~ msgid "required" +#~ msgstr "Benötigt" + +#, fuzzy +#~| msgid "Unknown User" +#~ msgid "unknown error" +#~ msgstr "Unbekannter Benutzer" + +#, fuzzy +#~| msgid "Profile" +#~ msgid "file" +#~ msgstr "Profil" + +#, fuzzy +#~| msgid "Organization" +#~ msgid "Syndication" +#~ msgstr "Organisation" + +#, fuzzy +#~| msgid "Enter Name" +#~ msgid "Enter a number." +#~ msgstr "Name einegeben" + +#, fuzzy +#~| msgid "End" +#~ msgid "and" +#~ msgstr "Ende" + +#, fuzzy +#~| msgid "December" +#~ msgid "Decimal number" +#~ msgstr "Dezember" + +#, fuzzy +#~| msgid "Subject" +#~ msgid "A JSON object" +#~ msgstr "Betreff" + +#, fuzzy +#~| msgid "One of the user fields is required!" +#~ msgid "This field is required." +#~ msgstr "Eines der Benutzerfelder ist benötigt!" + +#, fuzzy +#~| msgid "Enter Name" +#~ msgid "Enter a valid time." +#~ msgstr "Name einegeben" + +#, fuzzy +#~| msgid "Enter Name or Organization" +#~ msgid "Enter a valid duration." +#~ msgstr "Name oder Organisation angeben" + +#, fuzzy +#~| msgid "Delete Shift" +#~ msgid "Delete" +#~ msgstr "Schicht löschen" + +#, fuzzy +#~| msgid "Current Events" +#~ msgid "Currently" +#~ msgstr "Aktuelle Events" + +#, fuzzy +#~| msgid "M" +#~ msgid "PM" +#~ msgstr "M" + +#, fuzzy +#~| msgid "M" +#~ msgid "AM" +#~ msgstr "M" + +#, fuzzy +#~| msgid "Month" +#~ msgid "Mon" +#~ msgstr "Monat" + +#, fuzzy +#~| msgid "Tuesday" +#~ msgid "Tue" +#~ msgstr "Dienstag" + +#, fuzzy +#~| msgid "Friday" +#~ msgid "Fri" +#~ msgstr "Freitag" + +#, fuzzy +#~| msgid "Start" +#~ msgid "Sat" +#~ msgstr "Start" + +#, fuzzy +#~| msgid "Sunday" +#~ msgid "Sun" +#~ msgstr "Sonntag" + +#, fuzzy +#~| msgid "May" +#~ msgid "may" +#~ msgstr "Mai" + +#, fuzzy +#~| msgid "March" +#~ msgctxt "abbrev. month" +#~ msgid "March" +#~ msgstr "März" + +#, fuzzy +#~| msgid "April" +#~ msgctxt "abbrev. month" +#~ msgid "April" +#~ msgstr "April" + +#, fuzzy +#~| msgid "May" +#~ msgctxt "abbrev. month" +#~ msgid "May" +#~ msgstr "Mai" + +#, fuzzy +#~| msgid "June" +#~ msgctxt "abbrev. month" +#~ msgid "June" +#~ msgstr "Juni" + +#, fuzzy +#~| msgid "July" +#~ msgctxt "abbrev. month" +#~ msgid "July" +#~ msgstr "Juli" + +#, fuzzy +#~| msgid "January" +#~ msgctxt "alt. month" +#~ msgid "January" +#~ msgstr "Januar" + +#, fuzzy +#~| msgid "February" +#~ msgctxt "alt. month" +#~ msgid "February" +#~ msgstr "Februar" + +#, fuzzy +#~| msgid "March" +#~ msgctxt "alt. month" +#~ msgid "March" +#~ msgstr "März" + +#, fuzzy +#~| msgid "April" +#~ msgctxt "alt. month" +#~ msgid "April" +#~ msgstr "April" + +#, fuzzy +#~| msgid "May" +#~ msgctxt "alt. month" +#~ msgid "May" +#~ msgstr "Mai" + +#, fuzzy +#~| msgid "June" +#~ msgctxt "alt. month" +#~ msgid "June" +#~ msgstr "Juni" + +#, fuzzy +#~| msgid "July" +#~ msgctxt "alt. month" +#~ msgid "July" +#~ msgstr "Juli" + +#, fuzzy +#~| msgid "August" +#~ msgctxt "alt. month" +#~ msgid "August" +#~ msgstr "August" + +#, fuzzy +#~| msgid "September" +#~ msgctxt "alt. month" +#~ msgid "September" +#~ msgstr "September" + +#, fuzzy +#~| msgid "October" +#~ msgctxt "alt. month" +#~ msgid "October" +#~ msgstr "Oktober" + +#, fuzzy +#~| msgid "November" +#~ msgctxt "alt. month" +#~ msgid "November" +#~ msgstr "November" + +#, fuzzy +#~| msgid "December" +#~ msgctxt "alt. month" +#~ msgid "December" +#~ msgstr "Dezember" + +#, fuzzy +#~| msgid "Forbidden Method GET" +#~ msgid "Forbidden" +#~ msgstr "Verbotene Methode GET" + +#, fuzzy +#~| msgid "Close" +#~ msgid "close" +#~ msgstr "Schließen" + #, python-format #~ msgid "" #~ "\n" diff --git a/src/locale/en/LC_MESSAGES/django.po b/src/locale/en/LC_MESSAGES/django.po index 335980f..8a4f643 100644 --- a/src/locale/en/LC_MESSAGES/django.po +++ b/src/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-09-25 15:12+0000\n" +"POT-Creation-Date: 2026-04-17 05:19+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,1603 +18,96 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: venv/lib/python3.11/site-packages/click/_termui_impl.py:518 -#, python-brace-format -msgid "{editor}: Editing failed" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/_termui_impl.py:522 -#, python-brace-format -msgid "{editor}: Editing failed: {e}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1120 -msgid "Aborted!" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1309 -#: venv/lib/python3.11/site-packages/click/decorators.py:559 -msgid "Show this message and exit." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1340 -#: venv/lib/python3.11/site-packages/click/core.py:1370 -#, python-brace-format -msgid "(Deprecated) {text}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1387 -msgid "Options" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1413 -#, python-brace-format -msgid "Got unexpected extra argument ({args})" -msgid_plural "Got unexpected extra arguments ({args})" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/core.py:1429 -msgid "DeprecationWarning: The command {name!r} is deprecated." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1636 -msgid "Commands" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1668 -msgid "Missing command." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:1746 -msgid "No such command {name!r}." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2310 -msgid "Value must be an iterable." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2331 -#, python-brace-format -msgid "Takes {nargs} values but 1 was given." -msgid_plural "Takes {nargs} values but {len} were given." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/core.py:2778 -#, python-brace-format -msgid "env var: {var}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2808 -msgid "(dynamic)" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2821 -#, python-brace-format -msgid "default: {default}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/core.py:2834 -msgid "required" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/decorators.py:465 -#, python-format -msgid "%(prog)s, version %(version)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/decorators.py:528 -msgid "Show the version and exit." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:44 -#: venv/lib/python3.11/site-packages/click/exceptions.py:80 -#, python-brace-format -msgid "Error: {message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:72 -#, python-brace-format -msgid "Try '{command} {option}' for help." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:121 -#, python-brace-format -msgid "Invalid value: {message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:123 -#, python-brace-format -msgid "Invalid value for {param_hint}: {message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:179 -msgid "Missing argument" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:181 -msgid "Missing option" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:183 -msgid "Missing parameter" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:185 -#, python-brace-format -msgid "Missing {param_type}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:192 -#, python-brace-format -msgid "Missing parameter: {param_name}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:212 -#, python-brace-format -msgid "No such option: {name}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:224 -#, python-brace-format -msgid "Did you mean {possibility}?" -msgid_plural "(Possible options: {possibilities})" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:262 -msgid "unknown error" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/exceptions.py:269 -msgid "Could not open file {filename!r}: {message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/parser.py:231 -msgid "Argument {name!r} takes {nargs} values." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/parser.py:413 -msgid "Option {name!r} does not take a value." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/parser.py:474 -msgid "Option {name!r} requires an argument." -msgid_plural "Option {name!r} requires {nargs} arguments." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/shell_completion.py:319 -msgid "Shell completion is not supported for Bash versions older than 4.4." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/shell_completion.py:326 -msgid "Couldn't detect Bash version, shell completion is not supported." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:158 -msgid "Repeat for confirmation" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:174 -msgid "Error: The value you entered was invalid." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:176 -#, python-brace-format -msgid "Error: {e.message}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:187 -msgid "Error: The two entered values do not match." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:243 -msgid "Error: invalid input" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/termui.py:773 -msgid "Press any key to continue..." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:266 -#, python-brace-format -msgid "" -"Choose from:\n" -"\t{choices}" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:298 -msgid "{value!r} is not {choice}." -msgid_plural "{value!r} is not one of {choices}." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/types.py:392 -msgid "{value!r} does not match the format {format}." -msgid_plural "{value!r} does not match the formats {formats}." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/click/types.py:414 -msgid "{value!r} is not a valid {number_type}." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:470 -#, python-brace-format -msgid "{value} is not in the range {range}." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:611 -msgid "{value!r} is not a valid boolean." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:635 -msgid "{value!r} is not a valid UUID." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:822 -msgid "file" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:824 -msgid "directory" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:826 -msgid "path" -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:877 -msgid "{name} {filename!r} does not exist." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:886 -msgid "{name} {filename!r} is a file." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:894 -#, python-brace-format -msgid "{name} '{filename}' is a directory." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:903 -msgid "{name} {filename!r} is not readable." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:912 -msgid "{name} {filename!r} is not writable." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:921 -msgid "{name} {filename!r} is not executable." -msgstr "" - -#: venv/lib/python3.11/site-packages/click/types.py:988 -#, python-brace-format -msgid "{len_type} values are required, but {len_value} was given." -msgid_plural "{len_type} values are required, but {len_value} were given." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/colorfield/validators.py:9 -msgid "Enter a valid hex color, eg. #000000" -msgstr "" - -#: venv/lib/python3.11/site-packages/colorfield/validators.py:17 -msgid "Enter a valid hexa color, eg. #00000000" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/contrib/messages/apps.py:7 -msgid "Messages" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/contrib/sitemaps/apps.py:8 -msgid "Site Maps" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/contrib/staticfiles/apps.py:9 -msgid "Static Files" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/contrib/syndication/apps.py:7 -msgid "Syndication" -msgstr "" - -#. Translators: String used to replace omitted page numbers in elided page -#. range generated by paginators, e.g. [1, 2, '…', 5, 6, 7, '…', 9, 10]. -#: venv/lib/python3.11/site-packages/django/core/paginator.py:30 -msgid "…" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/paginator.py:50 -msgid "That page number is not an integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/paginator.py:52 -msgid "That page number is less than 1" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/paginator.py:57 -msgid "That page contains no results" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:23 -msgid "Enter a valid value." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:105 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:743 -msgid "Enter a valid URL." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:166 -msgid "Enter a valid integer." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:177 -msgid "Enter a valid email address." -msgstr "" - -#. Translators: "letters" means latin letters: a-z and A-Z. -#: venv/lib/python3.11/site-packages/django/core/validators.py:285 -msgid "" -"Enter a valid “slug” consisting of letters, numbers, underscores or hyphens." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:293 -msgid "" -"Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or " -"hyphens." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:305 -#: venv/lib/python3.11/site-packages/django/core/validators.py:313 -#: venv/lib/python3.11/site-packages/django/core/validators.py:342 -msgid "Enter a valid IPv4 address." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:322 -#: venv/lib/python3.11/site-packages/django/core/validators.py:343 -msgid "Enter a valid IPv6 address." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:334 -#: venv/lib/python3.11/site-packages/django/core/validators.py:341 -msgid "Enter a valid IPv4 or IPv6 address." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:377 -msgid "Enter only digits separated by commas." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:383 -#, python-format -msgid "Ensure this value is %(limit_value)s (it is %(show_value)s)." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:418 -#, python-format -msgid "Ensure this value is less than or equal to %(limit_value)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:427 -#, python-format -msgid "Ensure this value is greater than or equal to %(limit_value)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:437 -#, python-format -msgid "" -"Ensure this value has at least %(limit_value)d character (it has " -"%(show_value)d)." -msgid_plural "" -"Ensure this value has at least %(limit_value)d characters (it has " -"%(show_value)d)." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:455 -#, python-format -msgid "" -"Ensure this value has at most %(limit_value)d character (it has " -"%(show_value)d)." -msgid_plural "" -"Ensure this value has at most %(limit_value)d characters (it has " -"%(show_value)d)." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:478 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:343 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:378 -msgid "Enter a number." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:480 -#, python-format -msgid "Ensure that there are no more than %(max)s digit in total." -msgid_plural "Ensure that there are no more than %(max)s digits in total." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:485 -#, python-format -msgid "Ensure that there are no more than %(max)s decimal place." -msgid_plural "Ensure that there are no more than %(max)s decimal places." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:490 -#, python-format -msgid "" -"Ensure that there are no more than %(max)s digit before the decimal point." -msgid_plural "" -"Ensure that there are no more than %(max)s digits before the decimal point." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:559 -#, python-format -msgid "" -"File extension “%(extension)s” is not allowed. Allowed extensions are: " -"%(allowed_extensions)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/core/validators.py:620 -msgid "Null characters are not allowed." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/base.py:1364 -#: venv/lib/python3.11/site-packages/django/forms/models.py:894 -msgid "and" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/base.py:1366 -#, python-format -msgid "%(model_name)s with this %(field_labels)s already exists." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:129 -#, python-format -msgid "Value %(value)r is not a valid choice." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:130 -msgid "This field cannot be null." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:131 -msgid "This field cannot be blank." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:132 -#, python-format -msgid "%(model_name)s with this %(field_label)s already exists." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:136 -#, python-format -msgid "" -"%(field_label)s must be unique for %(date_field_label)s %(lookup_type)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:156 -#, python-format -msgid "Field of type: %(field_type)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1050 -#, python-format -msgid "“%(value)s” value must be either True or False." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1051 -#, python-format -msgid "“%(value)s” value must be either True, False, or None." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1053 -msgid "Boolean (Either True or False)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1094 -#, python-format -msgid "String (up to %(max_length)s)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1192 -msgid "Comma-separated integers" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1293 -#, python-format -msgid "" -"“%(value)s” value has an invalid date format. It must be in YYYY-MM-DD " -"format." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1297 -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1432 -#, python-format -msgid "" -"“%(value)s” value has the correct format (YYYY-MM-DD) but it is an invalid " -"date." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1301 -msgid "Date (without time)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1428 -#, python-format -msgid "" -"“%(value)s” value has an invalid format. It must be in YYYY-MM-DD HH:MM[:ss[." -"uuuuuu]][TZ] format." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1436 -#, python-format -msgid "" -"“%(value)s” value has the correct format (YYYY-MM-DD HH:MM[:ss[.uuuuuu]]" -"[TZ]) but it is an invalid date/time." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1441 -msgid "Date (with time)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1565 -#, python-format -msgid "“%(value)s” value must be a decimal number." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1567 -msgid "Decimal number" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1724 -#, python-format -msgid "" -"“%(value)s” value has an invalid format. It must be in [DD] [[HH:]MM:]ss[." -"uuuuuu] format." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1728 -#: src/shiftings/shifts/forms/template.py:40 -#: src/shiftings/shifts/models/template.py:23 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:11 -msgid "Duration" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1780 -msgid "Email address" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1805 -msgid "File path" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1883 -#, python-format -msgid "“%(value)s” value must be a float." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1885 -msgid "Floating point number" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1925 -#, python-format -msgid "“%(value)s” value must be an integer." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:1927 -msgid "Integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2019 -msgid "Big (8 byte) integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2036 -msgid "Small integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2044 -msgid "IPv4 address" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2075 -msgid "IP address" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2168 -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2169 -#, python-format -msgid "“%(value)s” value must be either None, True or False." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2171 -msgid "Boolean (Either True, False or None)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2222 -msgid "Positive big integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2237 -msgid "Positive integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2252 -msgid "Positive small integer" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2268 -#, python-format -msgid "Slug (up to %(max_length)s)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2304 -#: src/shiftings/mail/forms/mail.py:11 -msgid "Text" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2374 -#, python-format -msgid "" -"“%(value)s” value has an invalid format. It must be in HH:MM[:ss[.uuuuuu]] " -"format." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2378 -#, python-format -msgid "" -"“%(value)s” value has the correct format (HH:MM[:ss[.uuuuuu]]) but it is an " -"invalid time." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2382 -#: src/shiftings/cal/templates/cal/template/day_calendar_shift_types.html:23 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:197 -#: src/shiftings/shifts/templates/shifts/template/group.html:43 -#: src/shiftings/shifts/templates/shifts/template/group_template.html:23 -#: src/shiftings/shifts/templates/shifts/template/shift.html:10 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:33 -msgid "Time" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2490 -msgid "URL" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2514 -msgid "Raw binary data" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2579 -#, python-format -msgid "“%(value)s” is not a valid UUID." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/__init__.py:2581 -msgid "Universally unique identifier" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/files.py:232 -msgid "File" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/files.py:392 -msgid "Image" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/json.py:18 -msgid "A JSON object" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/json.py:20 -msgid "Value must be valid JSON." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:903 -#, python-format -msgid "%(model)s instance with %(field)s %(value)r does not exist." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:905 -msgid "Foreign Key (type determined by related field)" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:1204 -msgid "One-to-one relationship" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:1261 -#, python-format -msgid "%(from)s-%(to)s relationship" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:1263 -#, python-format -msgid "%(from)s-%(to)s relationships" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/db/models/fields/related.py:1311 -msgid "Many-to-many relationship" -msgstr "" - -#. Translators: If found as last label character, these punctuation -#. characters will prevent the default label_suffix to be appended to the label -#: venv/lib/python3.11/site-packages/django/forms/boundfield.py:176 -msgid ":?.!" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:91 -msgid "This field is required." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:298 -msgid "Enter a whole number." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:459 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1232 -msgid "Enter a valid date." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:482 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1233 -msgid "Enter a valid time." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:509 -msgid "Enter a valid date/time." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:543 -msgid "Enter a valid duration." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:544 -#, python-brace-format -msgid "The number of days must be between {min_days} and {max_days}." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:610 -msgid "No file was submitted. Check the encoding type on the form." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:611 -msgid "No file was submitted." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:612 -msgid "The submitted file is empty." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:614 -#, python-format -msgid "Ensure this filename has at most %(max)d character (it has %(length)d)." -msgid_plural "" -"Ensure this filename has at most %(max)d characters (it has %(length)d)." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:619 -msgid "Please either submit a file or check the clear checkbox, not both." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:685 -msgid "" -"Upload a valid image. The file you uploaded was either not an image or a " -"corrupted image." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:848 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:940 -#: venv/lib/python3.11/site-packages/django/forms/models.py:1563 -#, python-format -msgid "Select a valid choice. %(value)s is not one of the available choices." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:942 -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1061 -#: venv/lib/python3.11/site-packages/django/forms/models.py:1561 -msgid "Enter a list of values." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1062 -msgid "Enter a complete value." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1301 -msgid "Enter a valid UUID." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/fields.py:1331 -msgid "Enter a valid JSON." -msgstr "" - -#. Translators: This is the default suffix added to form field labels -#: venv/lib/python3.11/site-packages/django/forms/forms.py:98 -msgid ":" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/forms.py:248 -#: venv/lib/python3.11/site-packages/django/forms/forms.py:328 -#, python-format -msgid "(Hidden field %(name)s) %(error)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:67 -#, python-format -msgid "" -"ManagementForm data is missing or has been tampered with. Missing fields: " -"%(field_names)s. You may need to file a bug report if the issue persists." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:418 -#, python-format -msgid "Please submit at most %d form." -msgid_plural "Please submit at most %d forms." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:434 -#, python-format -msgid "Please submit at least %d form." -msgid_plural "Please submit at least %d forms." -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:470 -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:477 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:38 -msgid "Order" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/formsets.py:483 -msgid "Delete" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:887 -#, python-format -msgid "Please correct the duplicate data for %(field)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:892 -#, python-format -msgid "Please correct the duplicate data for %(field)s, which must be unique." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:899 -#, python-format -msgid "" -"Please correct the duplicate data for %(field_name)s which must be unique " -"for the %(lookup)s in %(date_field)s." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:908 -msgid "Please correct the duplicate values below." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:1335 -msgid "The inline value did not match the parent instance." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:1426 -msgid "Select a valid choice. That choice is not one of the available choices." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/models.py:1565 -#, python-format -msgid "“%(pk)s” is not a valid value." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/utils.py:204 -#, python-format -msgid "" -"%(datetime)s couldn’t be interpreted in time zone %(current_timezone)s; it " -"may be ambiguous or it may not exist." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:434 -msgid "Clear" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:435 -msgid "Currently" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:436 -msgid "Change" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:765 -#: src/shiftings/accounts/templates/accounts/user_detail.html:55 -#: src/shiftings/accounts/templates/accounts/user_detail.html:65 -msgid "Unknown" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:766 -#: src/shiftings/templates/generic/delete.html:9 -msgid "Yes" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/forms/widgets.py:767 -#: src/shiftings/templates/generic/delete.html:11 -msgid "No" -msgstr "" - -#. Translators: Please do not add spaces around commas. -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:858 -msgid "yes,no,maybe" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:888 -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:905 -#, python-format -msgid "%(size)d byte" -msgid_plural "%(size)d bytes" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:907 -#, python-format -msgid "%s KB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:909 -#, python-format -msgid "%s MB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:911 -#, python-format -msgid "%s GB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:913 -#, python-format -msgid "%s TB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/template/defaultfilters.py:915 -#, python-format -msgid "%s PB" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:77 -msgid "p.m." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:78 -msgid "a.m." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:83 -msgid "PM" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:84 -msgid "AM" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:155 -msgid "midnight" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dateformat.py:157 -msgid "noon" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:7 -#: src/shiftings/utils/time/week.py:13 -msgid "Monday" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:8 -#: src/shiftings/utils/time/week.py:14 -msgid "Tuesday" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:9 -#: src/shiftings/utils/time/week.py:15 -msgid "Wednesday" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:10 -#: src/shiftings/utils/time/week.py:16 -msgid "Thursday" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:11 -#: src/shiftings/utils/time/week.py:17 -msgid "Friday" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:12 -#: src/shiftings/utils/time/week.py:18 -msgid "Saturday" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:13 -#: src/shiftings/utils/time/week.py:19 -msgid "Sunday" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:16 -msgid "Mon" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:17 -msgid "Tue" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:18 -msgid "Wed" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:19 -msgid "Thu" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:20 -msgid "Fri" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:21 -msgid "Sat" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:22 -msgid "Sun" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:25 -#: src/shiftings/utils/time/month.py:10 -msgid "January" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:26 -#: src/shiftings/utils/time/month.py:11 -msgid "February" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:27 -#: src/shiftings/utils/time/month.py:12 -msgid "March" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:28 -#: src/shiftings/utils/time/month.py:13 -msgid "April" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:29 -#: src/shiftings/utils/time/month.py:14 -msgid "May" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:30 -#: src/shiftings/utils/time/month.py:15 -msgid "June" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:31 -#: src/shiftings/utils/time/month.py:16 -msgid "July" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:32 -#: src/shiftings/utils/time/month.py:17 -msgid "August" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:33 -#: src/shiftings/utils/time/month.py:18 -msgid "September" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:34 -#: src/shiftings/utils/time/month.py:19 -msgid "October" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:35 -#: src/shiftings/utils/time/month.py:20 -msgid "November" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:36 -#: src/shiftings/utils/time/month.py:21 -msgid "December" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:39 -msgid "jan" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:40 -msgid "feb" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:41 -msgid "mar" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:42 -msgid "apr" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:43 -msgid "may" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:44 -msgid "jun" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:45 -msgid "jul" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:46 -msgid "aug" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:47 -msgid "sep" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:48 -msgid "oct" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:49 -msgid "nov" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:50 -msgid "dec" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:53 -msgctxt "abbrev. month" -msgid "Jan." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:54 -msgctxt "abbrev. month" -msgid "Feb." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:55 -msgctxt "abbrev. month" -msgid "March" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:56 -msgctxt "abbrev. month" -msgid "April" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:57 -msgctxt "abbrev. month" -msgid "May" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:58 -msgctxt "abbrev. month" -msgid "June" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:59 -msgctxt "abbrev. month" -msgid "July" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:60 -msgctxt "abbrev. month" -msgid "Aug." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:61 -msgctxt "abbrev. month" -msgid "Sept." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:62 -msgctxt "abbrev. month" -msgid "Oct." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:63 -msgctxt "abbrev. month" -msgid "Nov." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:64 -msgctxt "abbrev. month" -msgid "Dec." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:67 -msgctxt "alt. month" -msgid "January" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:68 -msgctxt "alt. month" -msgid "February" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:69 -msgctxt "alt. month" -msgid "March" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:70 -msgctxt "alt. month" -msgid "April" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:71 -msgctxt "alt. month" -msgid "May" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:72 -msgctxt "alt. month" -msgid "June" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:73 -msgctxt "alt. month" -msgid "July" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:74 -msgctxt "alt. month" -msgid "August" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:75 -msgctxt "alt. month" -msgid "September" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:76 -msgctxt "alt. month" -msgid "October" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:77 -msgctxt "alt. month" -msgid "November" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/dates.py:78 -msgctxt "alt. month" -msgid "December" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/ipv6.py:8 -msgid "This is not a valid IPv6 address." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/text.py:77 -#, python-format -msgctxt "String to return when truncating text" -msgid "%(truncated_text)s…" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/text.py:253 -msgid "or" -msgstr "" - -#. Translators: This string is used as a separator between list elements -#: venv/lib/python3.11/site-packages/django/utils/text.py:272 -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:94 -msgid ", " -msgstr "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:9 -#, python-format -msgid "%(num)d year" -msgid_plural "%(num)d years" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:10 -#, python-format -msgid "%(num)d month" -msgid_plural "%(num)d months" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:11 -#, python-format -msgid "%(num)d week" -msgid_plural "%(num)d weeks" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:12 -#, python-format -msgid "%(num)d day" -msgid_plural "%(num)d days" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:13 -#, python-format -msgid "%(num)d hour" -msgid_plural "%(num)d hours" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/utils/timesince.py:14 -#, python-format -msgid "%(num)d minute" -msgid_plural "%(num)d minutes" -msgstr[0] "" -msgstr[1] "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:111 -msgid "Forbidden" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:112 -msgid "CSRF verification failed. Request aborted." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:116 -msgid "" -"You are seeing this message because this HTTPS site requires a “Referer " -"header” to be sent by your web browser, but none was sent. This header is " -"required for security reasons, to ensure that your browser is not being " -"hijacked by third parties." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:122 -msgid "" -"If you have configured your browser to disable “Referer” headers, please re-" -"enable them, at least for this site, or for HTTPS connections, or for “same-" -"origin” requests." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:127 -msgid "" -"If you are using the tag or " -"including the “Referrer-Policy: no-referrer” header, please remove them. The " -"CSRF protection requires the “Referer” header to do strict referer checking. " -"If you’re concerned about privacy, use alternatives like for links to third-party sites." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:136 -msgid "" -"You are seeing this message because this site requires a CSRF cookie when " -"submitting forms. This cookie is required for security reasons, to ensure " -"that your browser is not being hijacked by third parties." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:142 -msgid "" -"If you have configured your browser to disable cookies, please re-enable " -"them, at least for this site, or for “same-origin” requests." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/csrf.py:148 -msgid "More information is available with DEBUG=True." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:44 -msgid "No year specified" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:64 -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:115 -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:214 -msgid "Date out of range" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:94 -msgid "No month specified" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:147 -msgid "No day specified" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:194 -msgid "No week specified" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:349 -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:380 -#, python-format -msgid "No %(verbose_name_plural)s available" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:652 -#, python-format -msgid "" -"Future %(verbose_name_plural)s not available because %(class_name)s." -"allow_future is False." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/dates.py:692 -#, python-format -msgid "Invalid date string “%(datestr)s” given format “%(format)s”" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/detail.py:56 -#, python-format -msgid "No %(verbose_name)s found matching the query" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/list.py:70 -msgid "Page is not “last”, nor can it be converted to an int." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/list.py:77 -#, python-format -msgid "Invalid page (%(page_number)s): %(message)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/generic/list.py:169 -#, python-format -msgid "Empty list and “%(class_name)s.allow_empty” is False." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/static.py:39 -msgid "Directory indexes are not allowed here." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/static.py:41 -#, python-format -msgid "“%(path)s” does not exist" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/static.py:80 -#, python-format -msgid "Index of %(directory)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:7 -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:221 -msgid "The install worked successfully! Congratulations!" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:207 -#, python-format -msgid "" -"View release notes for Django %(version)s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:222 -#, python-format -msgid "" -"You are seeing this page because DEBUG=True is in your settings file and you have not " -"configured any URLs." -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:230 -msgid "Django Documentation" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:231 -msgid "Topics, references, & how-to’s" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:239 -msgid "Tutorial: A Polling App" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:240 -msgid "Get started with Django" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:248 -msgid "Django Community" -msgstr "" - -#: venv/lib/python3.11/site-packages/django/views/templates/default_urlconf.html:249 -msgid "Connect, get help, or contribute" +#: shiftings/accounts/forms/user_form.py:13 +msgid "Confirm password" msgstr "" -#: venv/lib/python3.11/site-packages/django_bootstrap5/components.py:26 -msgid "close" +#: shiftings/accounts/forms/user_form.py:31 +#: shiftings/accounts/forms/user_form.py:32 +msgid "Please enter matching passwords" msgstr "" -#: venv/lib/python3.11/site-packages/isort/main.py:158 -msgid "show this help message and exit" +#: shiftings/accounts/forms/user_form.py:65 +msgid "Cannot change first name, last name or email for LDAP users." msgstr "" -#: venv/lib/python3.11/site-packages/mypy/main.py:376 -#, python-format -msgid "%(prog)s: error: %(message)s\n" +#: shiftings/accounts/models/user.py:33 shiftings/templates/theme.html:28 +msgid "Light" msgstr "" -#: src/shiftings/accounts/forms/user_form.py:11 -msgid "Confirm password" +#: shiftings/accounts/models/user.py:34 shiftings/templates/theme.html:31 +msgid "Dark" msgstr "" -#: src/shiftings/accounts/forms/user_form.py:30 -msgid "Please enter matching passwords" +#: shiftings/accounts/models/user.py:35 shiftings/templates/theme.html:34 +msgid "Auto" msgstr "" -#: src/shiftings/accounts/models/user.py:32 -#: src/shiftings/accounts/templates/accounts/user_detail.html:52 -#: src/shiftings/shifts/models/participant.py:15 +#: shiftings/accounts/models/user.py:37 +#: shiftings/accounts/templates/accounts/user_detail.html:52 +#: shiftings/shifts/models/participant.py:15 msgid "Display Name" msgstr "" -#: src/shiftings/accounts/models/user.py:33 -#: src/shiftings/events/models/event.py:29 -#: src/shiftings/organizations/models/organization.py:28 +#: shiftings/accounts/models/user.py:38 shiftings/events/models/event.py:29 +#: shiftings/organizations/models/organization.py:28 msgid "Telephone Number" msgstr "" -#: src/shiftings/accounts/templates/accounts/confirm_email.html:7 +#: shiftings/accounts/models/user.py:42 +msgid "Theme Preference" +msgstr "" + +#: shiftings/accounts/templates/accounts/confirm_email.html:7 msgid "Go to Login" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_form.html:6 -#: src/shiftings/accounts/templates/accounts/login_multiple.html:12 +#: shiftings/accounts/templates/accounts/login_form.html:6 +#: shiftings/accounts/templates/accounts/login_multiple.html:12 msgid "Login with local Account" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_form.html:8 -#: src/shiftings/accounts/templates/accounts/login_multiple.html:20 +#: shiftings/accounts/templates/accounts/login_form.html:8 +#: shiftings/accounts/templates/accounts/login_multiple.html:20 msgid "Login with LDAP Account" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_form.html:14 -#: src/shiftings/accounts/views/auth.py:27 -#: src/shiftings/templates/top_nav.html:57 +#: shiftings/accounts/templates/accounts/login_form.html:14 +#: shiftings/accounts/views/auth.py:26 shiftings/templates/top_nav.html:61 msgid "Login" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_form.html:16 -#: src/shiftings/accounts/templates/accounts/password_reset/prompt.html:10 +#: shiftings/accounts/templates/accounts/login_form.html:16 +#: shiftings/accounts/templates/accounts/password_reset/prompt.html:10 msgid "Reset Password" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_form.html:18 +#: shiftings/accounts/templates/accounts/login_form.html:18 msgid "Return to selection" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_form.html:29 +#: shiftings/accounts/templates/accounts/login_form.html:29 msgid "You don't have an Account?" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_form.html:30 +#: shiftings/accounts/templates/accounts/login_form.html:30 msgid "Register here" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_form.html:32 +#: shiftings/accounts/templates/accounts/login_form.html:32 msgid "Or login using another Method" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_multiple.html:5 +#: shiftings/accounts/templates/accounts/login_multiple.html:5 msgid "Welcome to Shiftings" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_multiple.html:6 +#: shiftings/accounts/templates/accounts/login_multiple.html:6 msgid "" "To manage your shifts please authenticate with one of the given methods:" msgstr "" -#: src/shiftings/accounts/templates/accounts/login_multiple.html:31 +#: shiftings/accounts/templates/accounts/login_multiple.html:31 #, python-format msgid "" "\n" @@ -1622,28 +115,28 @@ msgid "" " " msgstr "" -#: src/shiftings/accounts/templates/accounts/logout.html:6 +#: shiftings/accounts/templates/accounts/logout.html:6 msgid "Successfully logged out" msgstr "" -#: src/shiftings/accounts/templates/accounts/logout.html:7 +#: shiftings/accounts/templates/accounts/logout.html:7 msgid "Login again" msgstr "" -#: src/shiftings/accounts/templates/accounts/password_reset/confirm.html:7 +#: shiftings/accounts/templates/accounts/password_reset/confirm.html:7 msgid "Change Password" msgstr "" -#: src/shiftings/accounts/templates/accounts/password_reset/done.html:4 +#: shiftings/accounts/templates/accounts/password_reset/done.html:4 msgid "Your password has been reset, check your email!" msgstr "" -#: src/shiftings/accounts/templates/accounts/password_reset/done.html:5 -#: src/shiftings/accounts/templates/accounts/password_reset/success.html:5 +#: shiftings/accounts/templates/accounts/password_reset/done.html:5 +#: shiftings/accounts/templates/accounts/password_reset/success.html:5 msgid "Back to login" msgstr "" -#: src/shiftings/accounts/templates/accounts/password_reset/prompt.html:4 +#: shiftings/accounts/templates/accounts/password_reset/prompt.html:4 msgid "" "\n" " If you are an local user enter your email-adress below to reset your " @@ -1651,15 +144,15 @@ msgid "" " " msgstr "" -#: src/shiftings/accounts/templates/accounts/password_reset/success.html:4 +#: shiftings/accounts/templates/accounts/password_reset/success.html:4 msgid "Your password has been successfully reset!" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:3 +#: shiftings/accounts/templates/accounts/user_detail.html:3 msgid "Confirm User Deletion" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:5 +#: shiftings/accounts/templates/accounts/user_detail.html:5 #, python-format msgid "" "\n" @@ -1669,128 +162,131 @@ msgid "" " " msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:17 +#: shiftings/accounts/templates/accounts/user_detail.html:17 msgid "Yes, I want to permanently delete my Data" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:19 +#: shiftings/accounts/templates/accounts/user_detail.html:19 msgid "Abort" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:29 -#: src/shiftings/organizations/models/membership.py:68 -#: src/shiftings/shifts/models/participant.py:13 -#: src/shiftings/templates/top_nav.html:47 +#: shiftings/accounts/templates/accounts/user_detail.html:29 +#: shiftings/organizations/models/membership.py:68 +#: shiftings/shifts/models/participant.py:13 +#: shiftings/templates/top_nav.html:48 msgid "User" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:37 -#: src/shiftings/accounts/templates/accounts/user_detail.html:57 +#: shiftings/accounts/templates/accounts/user_detail.html:37 +#: shiftings/accounts/templates/accounts/user_detail.html:57 msgid "Edit user data" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:48 -#: src/shiftings/organizations/forms/membership.py:16 +#: shiftings/accounts/templates/accounts/user_detail.html:48 +#: shiftings/organizations/forms/membership.py:16 msgid "Username" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:50 -#: src/shiftings/events/models/event.py:25 -#: src/shiftings/events/templates/events/event.html:85 -#: src/shiftings/organizations/models/membership.py:10 -#: src/shiftings/organizations/models/organization.py:24 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:195 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:239 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:278 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:388 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:39 -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:13 -#: src/shiftings/shifts/models/base.py:8 -#: src/shiftings/shifts/models/recurring.py:32 -#: src/shiftings/shifts/models/template.py:17 -#: src/shiftings/shifts/models/template_group.py:20 -#: src/shiftings/shifts/models/type.py:40 -#: src/shiftings/shifts/models/type_group.py:16 -#: src/shiftings/shifts/templates/shifts/group/list.html:6 -#: src/shiftings/shifts/templates/shifts/list.html:6 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:6 -#: src/shiftings/shifts/templates/shifts/template/group_list.html:6 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:5 -#: src/shiftings/shifts/templates/shifts/type_group/list.html:7 +#: shiftings/accounts/templates/accounts/user_detail.html:50 +#: shiftings/events/models/event.py:25 +#: shiftings/events/templates/events/event.html:85 +#: shiftings/organizations/models/membership.py:10 +#: shiftings/organizations/models/organization.py:24 +#: shiftings/organizations/templates/organizations/organization_admin.html:195 +#: shiftings/organizations/templates/organizations/organization_admin.html:239 +#: shiftings/organizations/templates/organizations/organization_admin.html:278 +#: shiftings/organizations/templates/organizations/organization_admin.html:388 +#: shiftings/organizations/templates/organizations/organization_settings.html:39 +#: shiftings/organizations/templates/organizations/user/claim_users.html:13 +#: shiftings/shifts/models/base.py:8 shiftings/shifts/models/recurring.py:32 +#: shiftings/shifts/models/template.py:17 +#: shiftings/shifts/models/template_group.py:20 +#: shiftings/shifts/models/type.py:40 shiftings/shifts/models/type_group.py:16 +#: shiftings/shifts/templates/shifts/group/list.html:6 +#: shiftings/shifts/templates/shifts/list.html:6 +#: shiftings/shifts/templates/shifts/recurring/list.html:6 +#: shiftings/shifts/templates/shifts/template/group_list.html:6 +#: shiftings/shifts/templates/shifts/template/template_form.html:5 +#: shiftings/shifts/templates/shifts/type_group/list.html:7 msgid "Name" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:58 +#: shiftings/accounts/templates/accounts/user_detail.html:55 +#: shiftings/accounts/templates/accounts/user_detail.html:65 +msgid "Unknown" +msgstr "" + +#: shiftings/accounts/templates/accounts/user_detail.html:58 msgid "Set Display Name" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:62 +#: shiftings/accounts/templates/accounts/user_detail.html:62 msgid "Email" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:64 +#: shiftings/accounts/templates/accounts/user_detail.html:64 msgid "Phone Number" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:66 +#: shiftings/accounts/templates/accounts/user_detail.html:66 msgid "Member since" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:68 +#: shiftings/accounts/templates/accounts/user_detail.html:68 msgid "Total Shifts" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:70 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:124 +#: shiftings/accounts/templates/accounts/user_detail.html:70 +#: shiftings/organizations/templates/organizations/organization_admin.html:124 msgid "Groups" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:71 -#: src/shiftings/events/templates/events/template/event.html:32 -#: src/shiftings/shifts/templates/shifts/template/template.html:19 +#: shiftings/accounts/templates/accounts/user_detail.html:71 +#: shiftings/events/templates/events/template/event.html:32 +#: shiftings/shifts/templates/shifts/template/template.html:19 msgid "None" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:77 +#: shiftings/accounts/templates/accounts/user_detail.html:77 msgid "Organizations" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:83 +#: shiftings/accounts/templates/accounts/user_detail.html:83 msgid "Hide organizations" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:102 -#: src/shiftings/events/templates/events/template/event.html:12 +#: shiftings/accounts/templates/accounts/user_detail.html:102 +#: shiftings/events/templates/events/template/event.html:12 msgid "No Logo available" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:125 +#: shiftings/accounts/templates/accounts/user_detail.html:125 msgid "Recent Shifts" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:127 -#: src/shiftings/accounts/templates/accounts/user_detail.html:132 +#: shiftings/accounts/templates/accounts/user_detail.html:127 +#: shiftings/accounts/templates/accounts/user_detail.html:132 msgid "Upcoming Shifts" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:136 +#: shiftings/accounts/templates/accounts/user_detail.html:136 msgid "Past Shifts" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:157 +#: shiftings/accounts/templates/accounts/user_detail.html:157 msgid "No recent Shifts" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_detail.html:159 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:135 +#: shiftings/accounts/templates/accounts/user_detail.html:159 +#: shiftings/organizations/templates/organizations/organization_shifts.html:135 msgid "No upcoming shifts" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_form.html:6 +#: shiftings/accounts/templates/accounts/user_form.html:6 msgid "Register" msgstr "" -#: src/shiftings/accounts/templates/accounts/user_form.html:8 +#: shiftings/accounts/templates/accounts/user_form.html:8 #, python-format msgid "" "\n" @@ -1798,155 +294,137 @@ msgid "" " " msgstr "" -#: src/shiftings/accounts/templates/accounts/user_form.html:19 -#: src/shiftings/templates/template/simple_form_modal.html:21 +#: shiftings/accounts/templates/accounts/user_form.html:19 +#: shiftings/templates/template/simple_form_modal.html:21 msgid "Submit" msgstr "" -#: src/shiftings/accounts/views/auth.py:83 +#: shiftings/accounts/views/auth.py:82 msgid "Error while creating the user instance!" msgstr "" -#: src/shiftings/accounts/views/auth.py:87 +#: shiftings/accounts/views/auth.py:86 msgid "Successfully logged in!" msgstr "" -#: src/shiftings/accounts/views/auth.py:90 +#: shiftings/accounts/views/auth.py:89 #, python-format msgid "Error while trying to authenticate with sso! %(message)s" msgstr "" -#: src/shiftings/accounts/views/auth.py:131 -#: src/shiftings/templates/top_nav.html:52 +#: shiftings/accounts/views/auth.py:130 shiftings/templates/top_nav.html:55 msgid "Logout" msgstr "" -#: src/shiftings/accounts/views/password.py:13 +#: shiftings/accounts/views/password.py:13 msgid "Password Reset" msgstr "" -#: src/shiftings/accounts/views/password.py:19 +#: shiftings/accounts/views/password.py:19 msgid "Confirm Password Reset" msgstr "" -#: src/shiftings/accounts/views/user.py:35 -#: src/shiftings/templates/top_nav.html:15 +#: shiftings/accounts/views/user.py:35 shiftings/templates/top_nav.html:15 msgid "My Shifts" msgstr "" -#: src/shiftings/accounts/views/user.py:100 +#: shiftings/accounts/views/user.py:101 msgid "Could not find your user." msgstr "" -#: src/shiftings/accounts/views/user.py:106 +#: shiftings/accounts/views/user.py:107 msgid "Your EMail was confirmed. You can now login." msgstr "" -#: src/shiftings/accounts/views/user.py:109 +#: shiftings/accounts/views/user.py:110 msgid "Your activation link was invalid." msgstr "" -#: src/shiftings/accounts/views/user.py:126 +#: shiftings/accounts/views/user.py:127 msgid "Error while deleting your data: Please confirm deletion!" msgstr "" -#: src/shiftings/cal/feed/event.py:44 +#: shiftings/cal/feed/event.py:44 msgid "Public Events" msgstr "" -#: src/shiftings/cal/forms/day_form.py:8 +#: shiftings/cal/forms/day_form.py:8 msgid "Select Day" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_base.html:14 -#: src/shiftings/cal/templates/cal/calendar_base.html:24 +#: shiftings/cal/templates/cal/calendar_base.html:14 +#: shiftings/cal/templates/cal/calendar_base.html:24 msgid "Day View" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_base.html:20 +#: shiftings/cal/templates/cal/calendar_base.html:20 msgid "Day (Detailed)" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_base.html:30 +#: shiftings/cal/templates/cal/calendar_base.html:30 msgid "Day (By Types)" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_base.html:37 +#: shiftings/cal/templates/cal/calendar_base.html:37 msgid "Month View" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_base.html:38 -#: src/shiftings/utils/time/month.py:25 -#: src/shiftings/utils/time/timerange.py:12 +#: shiftings/cal/templates/cal/calendar_base.html:38 +#: shiftings/utils/time/month.py:25 shiftings/utils/time/timerange.py:12 msgid "Month" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_base.html:42 -#: src/shiftings/cal/templates/cal/calendar_base.html:48 +#: shiftings/cal/templates/cal/calendar_base.html:42 +#: shiftings/cal/templates/cal/calendar_base.html:48 msgid "List View" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_base.html:44 +#: shiftings/cal/templates/cal/calendar_base.html:44 msgid "List (Detailed)" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_base.html:50 +#: shiftings/cal/templates/cal/calendar_base.html:50 msgid "List (By Types)" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_templates/cal_shift_create_entry.html:4 -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:106 -#: src/shiftings/events/templates/events/event.html:74 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:376 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:120 +#: shiftings/cal/templates/cal/calendar_templates/cal_shift_create_entry.html:4 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:106 +#: shiftings/events/templates/events/event.html:74 +#: shiftings/organizations/templates/organizations/organization_admin.html:376 +#: shiftings/organizations/templates/organizations/organization_shifts.html:120 msgid "Create Shift" msgstr "" -#: src/shiftings/cal/templates/cal/calendar_templates/recurring_shift_entry.html:5 +#: shiftings/cal/templates/cal/calendar_templates/recurring_shift_entry.html:5 msgid "Recurring" msgstr "" -#: src/shiftings/cal/templates/cal/day_calendar.html:14 +#: shiftings/cal/templates/cal/day_calendar.html:14 msgid "Previous Day" msgstr "" -#: src/shiftings/cal/templates/cal/day_calendar.html:22 +#: shiftings/cal/templates/cal/day_calendar.html:22 msgid "Jump to Date" msgstr "" -#: src/shiftings/cal/templates/cal/day_calendar.html:28 +#: shiftings/cal/templates/cal/day_calendar.html:28 msgid "Jump to Day" msgstr "" -#: src/shiftings/cal/templates/cal/day_calendar.html:30 +#: shiftings/cal/templates/cal/day_calendar.html:30 msgid "Select" msgstr "" -#: src/shiftings/cal/templates/cal/day_calendar.html:38 +#: shiftings/cal/templates/cal/day_calendar.html:38 msgid "Next Day" msgstr "" -#: src/shiftings/cal/templates/cal/list_calendar.html:12 +#: shiftings/cal/templates/cal/list_calendar.html:12 msgid "Shift list" -msgstr "Schichtenliste" - -#: src/shiftings/cal/templates/cal/template/day_calendar_shift_types.html:7 -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:48 -#: src/shiftings/shifts/templates/shifts/shift_participants.html:4 -msgid "Add with Name to Shift" -msgstr "" - -#: src/shiftings/cal/templates/cal/template/day_calendar_shift_types.html:33 -#: src/shiftings/organizations/models/membership.py:12 -msgid "Default" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_shift_types.html:38 -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:95 -msgid "No Shifts found with these parameters or planned on this day" -msgstr "" - -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:3 +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:3 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:3 #, python-format msgid "" "\n" @@ -1955,270 +433,293 @@ msgid "" " " msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:12 -#: src/shiftings/events/models/event.py:24 -#: src/shiftings/shifts/models/base.py:12 -#: src/shiftings/shifts/models/recurring.py:34 -#: src/shiftings/shifts/models/template_group.py:24 -#: src/shiftings/shifts/models/type.py:37 -#: src/shiftings/shifts/models/type_group.py:14 -#: src/shiftings/shifts/templates/shifts/create_shift.html:79 -#: src/shiftings/shifts/templates/shifts/list.html:9 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:9 -#: src/shiftings/shifts/templates/shifts/shift.html:47 -#: src/shiftings/shifts/templates/shifts/template/group.html:39 -#: src/shiftings/shifts/templates/shifts/template/group.html:45 -#: src/shiftings/shifts/templates/shifts/template/group_list.html:8 -#: src/shiftings/shifts/templates/shifts/template/group_template.html:25 +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:14 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:48 +#: shiftings/shifts/templates/shifts/shift_participants.html:4 +msgid "Add with Name to Shift" +msgstr "" + +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:30 +#: shiftings/organizations/templates/organizations/organization_admin.html:197 +#: shiftings/shifts/templates/shifts/template/group.html:43 +#: shiftings/shifts/templates/shifts/template/group_template.html:23 +#: shiftings/shifts/templates/shifts/template/shift.html:10 +#: shiftings/shifts/templates/shifts/template/template_form.html:33 +msgid "Time" +msgstr "" + +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:40 +#: shiftings/organizations/models/membership.py:12 +msgid "Default" +msgstr "" + +#: shiftings/cal/templates/cal/template/day_calendar_shift_types.html:45 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:95 +msgid "No Shifts found with these parameters or planned on this day" +msgstr "" + +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:12 +#: shiftings/events/models/event.py:24 shiftings/shifts/models/base.py:12 +#: shiftings/shifts/models/recurring.py:34 +#: shiftings/shifts/models/template_group.py:24 +#: shiftings/shifts/models/type.py:37 shiftings/shifts/models/type_group.py:14 +#: shiftings/shifts/templates/shifts/create_shift.html:89 +#: shiftings/shifts/templates/shifts/list.html:9 +#: shiftings/shifts/templates/shifts/recurring/list.html:9 +#: shiftings/shifts/templates/shifts/shift.html:47 +#: shiftings/shifts/templates/shifts/template/group.html:39 +#: shiftings/shifts/templates/shifts/template/group.html:45 +#: shiftings/shifts/templates/shifts/template/group_list.html:8 +#: shiftings/shifts/templates/shifts/template/group_template.html:25 msgid "Organization" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:13 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:13 msgid "Org" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:14 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:14 msgid "Shift" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:15 -#: src/shiftings/shifts/templates/shifts/create_shift.html:56 -#: src/shiftings/shifts/templates/shifts/template/shift.html:12 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:21 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:15 +#: shiftings/shifts/templates/shifts/create_shift.html:66 +#: shiftings/shifts/templates/shifts/template/shift.html:12 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:21 msgid "Participants" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:16 -#: src/shiftings/shifts/templates/shifts/list.html:10 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:16 +#: shiftings/shifts/templates/shifts/list.html:10 msgid "Start" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:17 -#: src/shiftings/shifts/templates/shifts/list.html:11 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:17 +#: shiftings/shifts/templates/shifts/list.html:11 msgid "End" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:36 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:7 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:36 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:7 msgid "Shift Details" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:61 -#: src/shiftings/shifts/templates/shifts/shift_participants.html:23 -#: src/shiftings/shifts/templates/shifts/template/slots_display.html:29 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:44 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:61 +#: shiftings/shifts/templates/shifts/shift_participants.html:23 +#: shiftings/shifts/templates/shifts/template/slots_display.html:29 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:44 msgid "Add me" msgstr "" -#: src/shiftings/cal/templates/cal/template/day_calendar_xs.html:112 -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:31 -#: src/shiftings/shifts/templates/shifts/edit_templates.html:26 +#: shiftings/cal/templates/cal/template/day_calendar_xs.html:112 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:31 +#: shiftings/shifts/templates/shifts/edit_templates.html:26 msgid "Add Shift" msgstr "" -#: src/shiftings/cal/templates/cal/template/month_calendar.html:7 +#: shiftings/cal/templates/cal/template/month_calendar.html:7 msgid "Previous Month" msgstr "" -#: src/shiftings/cal/templates/cal/template/month_calendar.html:15 +#: shiftings/cal/templates/cal/template/month_calendar.html:15 msgid "Next Month" msgstr "" -#: src/shiftings/cal/templates/cal/week_calendar.html:9 +#: shiftings/cal/templates/cal/week_calendar.html:9 msgid "Previous Week" msgstr "" -#: src/shiftings/cal/templates/cal/week_calendar.html:13 +#: shiftings/cal/templates/cal/week_calendar.html:13 msgid "Week" msgstr "" -#: src/shiftings/cal/templates/cal/week_calendar.html:17 +#: shiftings/cal/templates/cal/week_calendar.html:17 msgid "Next Week" msgstr "" -#: src/shiftings/cal/views/day_calendar.py:24 +#: shiftings/cal/views/day_calendar.py:24 #, python-brace-format msgid "Day {date}" msgstr "" -#: src/shiftings/cal/views/day_calendar.py:25 +#: shiftings/cal/views/day_calendar.py:25 msgid "Day Overview" msgstr "" -#: src/shiftings/cal/views/list.py:24 -#: src/shiftings/organizations/templates/organizations/template/org_info.html:6 +#: shiftings/cal/views/list.py:24 +#: shiftings/organizations/templates/organizations/template/org_info.html:6 msgid "Shift Overview" msgstr "" -#: src/shiftings/cal/views/month_calendar.py:24 +#: shiftings/cal/views/month_calendar.py:24 #, python-brace-format msgid "Month {year}/{month:0>2}" msgstr "" -#: src/shiftings/cal/views/month_calendar.py:26 -#: src/shiftings/events/templates/events/event.html:52 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:162 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:28 +#: shiftings/cal/views/month_calendar.py:26 +#: shiftings/events/templates/events/event.html:52 +#: shiftings/organizations/templates/organizations/organization_admin.html:162 +#: shiftings/organizations/templates/organizations/organization_shifts.html:28 msgid "Month Overview" msgstr "" -#: src/shiftings/events/forms/event.py:24 +#: shiftings/events/forms/event.py:24 #, python-format msgid "Start date %(start)s was after end_date %(end)s" msgstr "" -#: src/shiftings/events/models/event.py:26 -#: src/shiftings/events/templates/events/event.html:19 -#: src/shiftings/organizations/models/organization.py:25 -#: src/shiftings/organizations/templates/organizations/template/org_info.html:58 +#: shiftings/events/models/event.py:26 +#: shiftings/events/templates/events/event.html:19 +#: shiftings/organizations/models/organization.py:25 +#: shiftings/organizations/templates/organizations/template/org_info.html:58 msgid "Logo" msgstr "" -#: src/shiftings/events/models/event.py:28 -#: src/shiftings/organizations/models/organization.py:27 +#: shiftings/events/models/event.py:28 +#: shiftings/organizations/models/organization.py:27 msgid "E-Mail" msgstr "" -#: src/shiftings/events/models/event.py:30 -#: src/shiftings/organizations/models/organization.py:29 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:81 -#: src/shiftings/organizations/templates/organizations/template/organization.html:27 +#: shiftings/events/models/event.py:30 +#: shiftings/organizations/models/organization.py:29 +#: shiftings/organizations/templates/organizations/organization_shifts.html:81 +#: shiftings/organizations/templates/organizations/template/organization.html:27 msgid "Website" msgstr "" -#: src/shiftings/events/models/event.py:32 +#: shiftings/events/models/event.py:32 msgid "Start Date" msgstr "" -#: src/shiftings/events/models/event.py:32 +#: shiftings/events/models/event.py:32 msgid "Earliest date where there are shifts available" msgstr "" -#: src/shiftings/events/models/event.py:33 +#: shiftings/events/models/event.py:33 msgid "End Date" msgstr "" -#: src/shiftings/events/models/event.py:33 +#: shiftings/events/models/event.py:33 msgid "Latest date where there are shifts available" msgstr "" -#: src/shiftings/events/models/event.py:35 -#: src/shiftings/organizations/models/organization.py:32 +#: shiftings/events/models/event.py:35 +#: shiftings/organizations/models/organization.py:32 msgid "Description" msgstr "" -#: src/shiftings/events/models/event.py:55 +#: shiftings/events/models/event.py:55 #, python-brace-format msgid "{name} (by {organization})" msgstr "" -#: src/shiftings/events/templates/events/event.html:10 +#: shiftings/events/templates/events/event.html:10 msgid "Edit Event" msgstr "" -#: src/shiftings/events/templates/events/event.html:55 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:165 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:31 +#: shiftings/events/templates/events/event.html:55 +#: shiftings/organizations/templates/organizations/organization_admin.html:165 +#: shiftings/organizations/templates/organizations/organization_shifts.html:31 msgid "Complete Overview" msgstr "" -#: src/shiftings/events/templates/events/event.html:69 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:363 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:109 +#: shiftings/events/templates/events/event.html:69 +#: shiftings/organizations/templates/organizations/organization_admin.html:363 +#: shiftings/organizations/templates/organizations/organization_shifts.html:109 msgid "Shifts" msgstr "" -#: src/shiftings/events/templates/events/event.html:71 -#: src/shiftings/events/templates/events/event.html:72 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:371 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:112 +#: shiftings/events/templates/events/event.html:71 +#: shiftings/events/templates/events/event.html:72 +#: shiftings/organizations/templates/organizations/organization_admin.html:371 +#: shiftings/organizations/templates/organizations/organization_shifts.html:112 msgid "ical Export" msgstr "" -#: src/shiftings/events/templates/events/event.html:86 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:389 -#: src/shiftings/shifts/templates/shifts/list.html:7 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:7 -#: src/shiftings/shifts/templates/shifts/template/shift.html:8 -#: src/shiftings/shifts/templates/shifts/template/template.html:7 +#: shiftings/events/templates/events/event.html:86 +#: shiftings/organizations/templates/organizations/organization_admin.html:389 +#: shiftings/shifts/templates/shifts/list.html:7 +#: shiftings/shifts/templates/shifts/recurring/list.html:7 +#: shiftings/shifts/templates/shifts/template/shift.html:8 +#: shiftings/shifts/templates/shifts/template/template.html:7 msgid "Type" msgstr "" -#: src/shiftings/events/templates/events/event.html:87 -#: src/shiftings/mail/forms/mail.py:39 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:241 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:390 -#: src/shiftings/shifts/models/template_group.py:26 -#: src/shiftings/shifts/templates/shifts/shift.html:43 -#: src/shiftings/shifts/templates/shifts/template/group_list.html:9 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:17 -#: src/shiftings/shifts/templates/shifts/template/template.html:13 +#: shiftings/events/templates/events/event.html:87 +#: shiftings/mail/forms/mail.py:55 +#: shiftings/organizations/templates/organizations/organization_admin.html:241 +#: shiftings/organizations/templates/organizations/organization_admin.html:390 +#: shiftings/shifts/models/template_group.py:26 +#: shiftings/shifts/templates/shifts/shift.html:43 +#: shiftings/shifts/templates/shifts/template/group_list.html:9 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:17 +#: shiftings/shifts/templates/shifts/template/template.html:13 msgid "Start Time" msgstr "" -#: src/shiftings/events/templates/events/event.html:88 -#: src/shiftings/mail/forms/mail.py:40 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:391 -#: src/shiftings/shifts/templates/shifts/shift.html:45 -#: src/shiftings/shifts/templates/shifts/template/template.html:15 +#: shiftings/events/templates/events/event.html:88 +#: shiftings/mail/forms/mail.py:56 +#: shiftings/organizations/templates/organizations/organization_admin.html:391 +#: shiftings/shifts/templates/shifts/shift.html:45 +#: shiftings/shifts/templates/shifts/template/template.html:15 msgid "End Time" msgstr "" -#: src/shiftings/events/templates/events/event.html:89 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:198 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:240 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:392 -#: src/shiftings/shifts/models/base.py:9 -#: src/shiftings/shifts/models/template_group.py:21 -#: src/shiftings/shifts/templates/shifts/list.html:8 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:8 -#: src/shiftings/shifts/templates/shifts/shift.html:41 -#: src/shiftings/shifts/templates/shifts/template/group.html:41 -#: src/shiftings/shifts/templates/shifts/template/group_list.html:7 -#: src/shiftings/shifts/templates/shifts/template/group_template.html:21 -#: src/shiftings/shifts/templates/shifts/template/shift_card.html:12 -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:18 +#: shiftings/events/templates/events/event.html:89 +#: shiftings/organizations/templates/organizations/organization_admin.html:198 +#: shiftings/organizations/templates/organizations/organization_admin.html:240 +#: shiftings/organizations/templates/organizations/organization_admin.html:392 +#: shiftings/shifts/models/base.py:9 +#: shiftings/shifts/models/template_group.py:21 +#: shiftings/shifts/templates/shifts/list.html:8 +#: shiftings/shifts/templates/shifts/recurring/list.html:8 +#: shiftings/shifts/templates/shifts/shift.html:41 +#: shiftings/shifts/templates/shifts/template/group.html:41 +#: shiftings/shifts/templates/shifts/template/group_list.html:7 +#: shiftings/shifts/templates/shifts/template/group_template.html:21 +#: shiftings/shifts/templates/shifts/template/shift_card.html:12 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:18 msgid "Place" msgstr "" -#: src/shiftings/events/templates/events/event.html:90 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:393 +#: shiftings/events/templates/events/event.html:90 +#: shiftings/organizations/templates/organizations/organization_admin.html:393 msgid "Users/Required/Max" msgstr "" -#: src/shiftings/events/templates/events/event.html:108 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:416 +#: shiftings/events/templates/events/event.html:108 +#: shiftings/organizations/templates/organizations/organization_admin.html:416 msgid "No current shifts" msgstr "" -#: src/shiftings/events/templates/events/list.html:5 -#: src/shiftings/events/templates/events/list.html:13 -#: src/shiftings/events/templates/events/list.html:22 +#: shiftings/events/templates/events/list.html:5 +#: shiftings/events/templates/events/list.html:13 +#: shiftings/events/templates/events/list.html:22 msgid "All Events" msgstr "" -#: src/shiftings/events/templates/events/list.html:7 -#: src/shiftings/templates/top_nav.html:22 +#: shiftings/events/templates/events/list.html:7 +#: shiftings/templates/top_nav.html:22 msgid "Current Events" msgstr "" -#: src/shiftings/events/templates/events/list.html:16 +#: shiftings/events/templates/events/list.html:16 msgid "All upcoming Events" msgstr "" -#: src/shiftings/events/templates/events/list.html:20 +#: shiftings/events/templates/events/list.html:20 msgid "Enter Name or Organization" msgstr "" -#: src/shiftings/events/templates/events/list.html:26 +#: shiftings/events/templates/events/list.html:26 msgid "Future Events" msgstr "" -#: src/shiftings/events/templates/events/list.html:39 +#: shiftings/events/templates/events/list.html:39 msgid "No Events found." msgstr "" -#: src/shiftings/events/templates/events/template/event.html:23 +#: shiftings/events/templates/events/template/event.html:23 #, python-format msgid "" "\n" @@ -2226,262 +727,276 @@ msgid "" " " msgstr "" -#: src/shiftings/events/templates/events/template/event.html:31 +#: shiftings/events/templates/events/template/event.html:31 msgid "Open Shifts" msgstr "" -#: src/shiftings/events/templates/events/template/event.html:32 +#: shiftings/events/templates/events/template/event.html:32 msgid "Required People" msgstr "" -#: src/shiftings/events/templates/events/template/event.html:33 +#: shiftings/events/templates/events/template/event.html:33 msgid "Visibility" msgstr "" -#: src/shiftings/events/templates/events/template/event.html:33 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:350 +#: shiftings/events/templates/events/template/event.html:33 +#: shiftings/organizations/templates/organizations/organization_admin.html:350 msgid "Public,Private,Unknown" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:2 +#: shiftings/events/templates/events/template/event_stats.html:2 msgid "Shifts that are below the required amount of users" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:3 +#: shiftings/events/templates/events/template/event_stats.html:3 msgid "Shifts missing users" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:6 +#: shiftings/events/templates/events/template/event_stats.html:6 msgid "Shifts that are below the required and maximum amount of users" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:7 +#: shiftings/events/templates/events/template/event_stats.html:7 msgid "Shifts with open slots" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:10 +#: shiftings/events/templates/events/template/event_stats.html:10 msgid "Number of Shift slots filled with users" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:11 +#: shiftings/events/templates/events/template/event_stats.html:11 msgid "Filled Slots" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:12 -#: src/shiftings/events/templates/events/template/event_stats.html:16 +#: shiftings/events/templates/events/template/event_stats.html:12 +#: shiftings/events/templates/events/template/event_stats.html:16 msgid "No Shifts available" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:14 +#: shiftings/events/templates/events/template/event_stats.html:14 msgid "Number of Shift slots that are still open" msgstr "" -#: src/shiftings/events/templates/events/template/event_stats.html:15 +#: shiftings/events/templates/events/template/event_stats.html:15 msgid "Open Slots" msgstr "" -#: src/shiftings/mail/forms/mail.py:10 +#: shiftings/mail/forms/mail.py:13 msgid "Subject" msgstr "" -#: src/shiftings/mail/forms/mail.py:12 +#: shiftings/mail/forms/mail.py:14 +msgid "Text" +msgstr "" + +#: shiftings/mail/forms/mail.py:15 msgid "Attachments" msgstr "" -#: src/shiftings/mail/forms/mail.py:22 +#: shiftings/mail/forms/mail.py:26 +msgid "Each attachment must be smaller than 10 MB." +msgstr "" + +#: shiftings/mail/forms/mail.py:30 +msgid "Total attachment size must be smaller than 25 MB." +msgstr "" + +#: shiftings/mail/forms/mail.py:38 msgid "" "Send the mail only to specific membership types. Or leave blank to send to " "all." msgstr "" -#: src/shiftings/mail/forms/mail.py:37 +#: shiftings/mail/forms/mail.py:53 msgid "" "Send the mail only to specific shift types. Or leave blank to send to all." msgstr "" -#: src/shiftings/mail/templates/mail/mail.html:8 +#: shiftings/mail/forms/mail.py:71 +msgid "Start time must be before end time." +msgstr "" + +#: shiftings/mail/templates/mail/mail.html:8 msgid "Send Mail" msgstr "" -#: src/shiftings/mail/views/mail.py:45 +#: shiftings/mail/views/mail.py:45 #, python-brace-format msgid "E-Mail sent to {count} user(s)." msgstr "" -#: src/shiftings/organizations/forms/membership.py:40 -#: src/shiftings/shifts/forms/participant.py:57 +#: shiftings/organizations/forms/membership.py:40 +#: shiftings/shifts/forms/participant.py:57 msgid "The user you entered could not be found." msgstr "" -#: src/shiftings/organizations/models/membership.py:11 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:68 -#: src/shiftings/templates/top_nav.html:50 +#: shiftings/organizations/models/membership.py:11 +#: shiftings/organizations/templates/organizations/organization_admin.html:68 +#: shiftings/templates/top_nav.html:51 msgid "Admin" msgstr "" -#: src/shiftings/organizations/models/membership.py:13 +#: shiftings/organizations/models/membership.py:13 msgid "Permissions" msgstr "" -#: src/shiftings/organizations/models/membership.py:19 +#: shiftings/organizations/models/membership.py:19 msgid "edit organization details" msgstr "" -#: src/shiftings/organizations/models/membership.py:20 +#: shiftings/organizations/models/membership.py:20 msgid "see members of the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:21 +#: shiftings/organizations/models/membership.py:21 msgid "see shift participation statistics" msgstr "" -#: src/shiftings/organizations/models/membership.py:22 +#: shiftings/organizations/models/membership.py:22 msgid "send emails to everyone in the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:24 +#: shiftings/organizations/models/membership.py:24 msgid "create and update membership types for the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:25 +#: shiftings/organizations/models/membership.py:25 msgid "add and remove members for the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:27 +#: shiftings/organizations/models/membership.py:27 msgid "create and update events for the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:29 +#: shiftings/organizations/models/membership.py:29 msgid "create and update recurring shifts for the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:30 +#: shiftings/organizations/models/membership.py:30 msgid "create and update shifts templates for the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:31 +#: shiftings/organizations/models/membership.py:31 msgid "create and update shifts for the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:32 +#: shiftings/organizations/models/membership.py:32 msgid "delete uncompleted shifts for the organization" msgstr "" -#: src/shiftings/organizations/models/membership.py:34 +#: shiftings/organizations/models/membership.py:34 msgid "remove others from shifts" msgstr "" -#: src/shiftings/organizations/models/membership.py:35 +#: shiftings/organizations/models/membership.py:35 msgid "add other users that are not members of the organization to shifts" msgstr "" -#: src/shiftings/organizations/models/membership.py:36 +#: shiftings/organizations/models/membership.py:36 msgid "add other organization members to shifts" msgstr "" -#: src/shiftings/organizations/models/membership.py:37 +#: shiftings/organizations/models/membership.py:37 msgid "participate in shifts" msgstr "" -#: src/shiftings/organizations/models/membership.py:38 +#: shiftings/organizations/models/membership.py:38 msgid "add self/others (depending on other permissions) to past shifts" msgstr "" -#: src/shiftings/organizations/models/membership.py:57 +#: shiftings/organizations/models/membership.py:57 #, python-brace-format msgid "{name} (Admin)" msgstr "" -#: src/shiftings/organizations/models/membership.py:59 +#: shiftings/organizations/models/membership.py:59 #, python-brace-format msgid "{name} (Default)" msgstr "" -#: src/shiftings/organizations/models/membership.py:65 +#: shiftings/organizations/models/membership.py:65 msgid "Membership Type" msgstr "" -#: src/shiftings/organizations/models/membership.py:69 -#: src/shiftings/shifts/models/type.py:38 +#: shiftings/organizations/models/membership.py:69 +#: shiftings/shifts/models/type.py:38 msgid "Group" msgstr "" -#: src/shiftings/organizations/models/membership.py:100 +#: shiftings/organizations/models/membership.py:100 msgid "Membership can only be either user or group, not both." msgstr "" -#: src/shiftings/organizations/models/membership.py:102 +#: shiftings/organizations/models/membership.py:102 msgid "Membership must consist of a user or a group." msgstr "" -#: src/shiftings/organizations/models/organization.py:30 -msgid "Include Protocol i.E. https://example.com" +#: shiftings/organizations/models/organization.py:30 +msgid "Include protocol, e.g." msgstr "" -#: src/shiftings/organizations/models/organization.py:33 -#: src/shiftings/shifts/models/base.py:24 -#: src/shiftings/shifts/models/recurring.py:56 -#: src/shiftings/shifts/models/recurring.py:63 -#: src/shiftings/shifts/models/shift.py:32 -#: src/shiftings/shifts/models/template.py:33 +#: shiftings/organizations/models/organization.py:33 +#: shiftings/shifts/models/base.py:24 shiftings/shifts/models/recurring.py:56 +#: shiftings/shifts/models/recurring.py:63 shiftings/shifts/models/shift.py:32 +#: shiftings/shifts/models/template.py:33 #, python-brace-format msgid "A maximum of {amount} characters is allowed" msgstr "" -#: src/shiftings/organizations/models/organization.py:34 +#: shiftings/organizations/models/organization.py:34 msgid "Confirm Participants" msgstr "" -#: src/shiftings/organizations/models/organization.py:35 +#: shiftings/organizations/models/organization.py:35 msgid "Whether the participants can be confirmed or not. Default: False" msgstr "" -#: src/shiftings/organizations/models/organization.py:49 +#: shiftings/organizations/models/organization.py:49 msgid "manage all organizations" msgstr "" -#: src/shiftings/organizations/models/user.py:10 +#: shiftings/organizations/models/user.py:10 msgid "Claimed by" msgstr "" -#: src/shiftings/organizations/templates/organizations/list.html:5 +#: shiftings/organizations/templates/organizations/list.html:5 msgid "All Organizations" msgstr "" -#: src/shiftings/organizations/templates/organizations/list.html:7 -#: src/shiftings/templates/top_nav.html:18 +#: shiftings/organizations/templates/organizations/list.html:7 +#: shiftings/templates/top_nav.html:18 msgid "My Organizations" msgstr "" -#: src/shiftings/organizations/templates/organizations/list.html:12 +#: shiftings/organizations/templates/organizations/list.html:12 msgid "Enter Name" msgstr "" -#: src/shiftings/organizations/templates/organizations/list.html:14 +#: shiftings/organizations/templates/organizations/list.html:14 msgid "Add new Organization" msgstr "" -#: src/shiftings/organizations/templates/organizations/list.html:25 +#: shiftings/organizations/templates/organizations/list.html:25 msgid "No Organizations found." msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:6 +#: shiftings/organizations/templates/organizations/organization_admin.html:6 msgid "Show membership Permissions" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:10 +#: shiftings/organizations/templates/organizations/organization_admin.html:10 msgid "All Permissions" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:19 +#: shiftings/organizations/templates/organizations/organization_admin.html:19 msgid "No permissions" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:26 +#: shiftings/organizations/templates/organizations/organization_admin.html:26 msgid "Add member" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:41 +#: shiftings/organizations/templates/organizations/organization_admin.html:41 #, python-format msgid "" "\n" @@ -2489,183 +1004,187 @@ msgid "" " " msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:48 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:8 +#: shiftings/organizations/templates/organizations/organization_admin.html:48 +#: shiftings/organizations/templates/organizations/organization_shifts.html:8 msgid "Member Shift Summary" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:54 +#: shiftings/organizations/templates/organizations/organization_admin.html:54 msgid "Add Membership Type" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:78 +#: shiftings/organizations/templates/organizations/organization_admin.html:78 msgid "Show user permissions" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:81 +#: shiftings/organizations/templates/organizations/organization_admin.html:81 msgid "Edit Membership Type" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:90 +#: shiftings/organizations/templates/organizations/organization_admin.html:90 msgid "Add Member" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:100 +#: shiftings/organizations/templates/organizations/organization_admin.html:100 msgid "No Members of this type!" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:106 +#: shiftings/organizations/templates/organizations/organization_admin.html:106 msgid "Direct Members" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:150 +#: shiftings/organizations/templates/organizations/organization_admin.html:150 msgid "Next Shift" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:154 +#: shiftings/organizations/templates/organizations/organization_admin.html:154 msgid "No Shifts planned" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:179 -#: src/shiftings/shifts/templates/shifts/template/group.html:52 +#: shiftings/organizations/templates/organizations/organization_admin.html:179 +#: shiftings/shifts/templates/shifts/template/group.html:52 msgid "Recurring Shifts" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:184 +#: shiftings/organizations/templates/organizations/organization_admin.html:184 msgid "Add recurring shift" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:196 +#: shiftings/organizations/templates/organizations/organization_admin.html:196 msgid "Shift count" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:199 +#: shiftings/organizations/templates/organizations/organization_admin.html:199 msgid "Status" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:210 +#: shiftings/organizations/templates/organizations/organization_admin.html:210 msgid "Inactive,Active,Undefined" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:216 +#: shiftings/organizations/templates/organizations/organization_admin.html:216 msgid "No recurring shifts" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:223 +#: shiftings/organizations/templates/organizations/organization_admin.html:223 msgid "Shifts Templates" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:228 +#: shiftings/organizations/templates/organizations/organization_admin.html:228 msgid "Add shifts template" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:255 -#: src/shiftings/organizations/templates/organizations/organization_admin.html:309 +#: shiftings/organizations/templates/organizations/organization_admin.html:255 +#: shiftings/organizations/templates/organizations/organization_admin.html:309 msgid "No shifts templates" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:262 +#: shiftings/organizations/templates/organizations/organization_admin.html:262 msgid "Shifts Types" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:267 +#: shiftings/organizations/templates/organizations/organization_admin.html:267 msgid "Add Shift Type" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:279 +#: shiftings/organizations/templates/organizations/organization_admin.html:279 msgid "Color" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:280 -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:15 +#: shiftings/organizations/templates/organizations/organization_admin.html:280 +#: shiftings/organizations/templates/organizations/user/claim_users.html:15 msgid "Shift Count" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:281 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:41 -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:16 +#: shiftings/organizations/templates/organizations/organization_admin.html:281 +#: shiftings/organizations/templates/organizations/organization_settings.html:41 +#: shiftings/organizations/templates/organizations/user/claim_users.html:16 msgid "Action" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:290 +#: shiftings/organizations/templates/organizations/organization_admin.html:290 msgid "Shift Type Background Color" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:317 +#: shiftings/organizations/templates/organizations/organization_admin.html:317 msgid "Upcoming Events" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:321 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:50 +#: shiftings/organizations/templates/organizations/organization_admin.html:321 +#: shiftings/organizations/templates/organizations/organization_shifts.html:50 msgid "Expired Events" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:326 -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:55 +#: shiftings/organizations/templates/organizations/organization_admin.html:326 +#: shiftings/organizations/templates/organizations/organization_shifts.html:55 msgid "Add Event" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:356 +#: shiftings/organizations/templates/organizations/organization_admin.html:356 msgid "No upcoming Events planned" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_admin.html:367 +#: shiftings/organizations/templates/organizations/organization_admin.html:367 msgid "Claim Legacy Shifts" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:19 -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:36 -#: src/shiftings/shifts/templates/shifts/edit_templates.html:31 -#: src/shiftings/shifts/templates/shifts/recurring/form.html:25 -#: src/shiftings/templates/generic/create_or_update.html:20 -#: src/shiftings/templates/generic/formset.html:17 +#: shiftings/organizations/templates/organizations/organization_settings.html:19 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:36 +#: shiftings/shifts/templates/shifts/edit_templates.html:31 +#: shiftings/shifts/templates/shifts/recurring/form.html:25 +#: shiftings/templates/generic/create_or_update.html:20 +#: shiftings/templates/generic/formset.html:17 msgid "Save" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:26 +#: shiftings/organizations/templates/organizations/organization_settings.html:26 msgid "Shift Type Groups" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:29 +#: shiftings/organizations/templates/organizations/organization_settings.html:29 msgid "Create Type Group" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:40 +#: shiftings/organizations/templates/organizations/organization_settings.html:38 +msgid "Order" +msgstr "" + +#: shiftings/organizations/templates/organizations/organization_settings.html:40 msgid "Types" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:61 +#: shiftings/organizations/templates/organizations/organization_settings.html:61 msgid "Edit Type Group" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:72 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:77 +#: shiftings/organizations/templates/organizations/organization_settings.html:72 +#: shiftings/organizations/templates/organizations/organization_settings.html:77 msgid "Move Up" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_settings.html:84 -#: src/shiftings/organizations/templates/organizations/organization_settings.html:89 +#: shiftings/organizations/templates/organizations/organization_settings.html:84 +#: shiftings/organizations/templates/organizations/organization_settings.html:89 msgid "Move Down" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:11 +#: shiftings/organizations/templates/organizations/organization_shifts.html:11 msgid "Full Summary" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:46 +#: shiftings/organizations/templates/organizations/organization_shifts.html:46 msgid "Upcoming Event" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:66 -#: src/shiftings/shifts/models/shift.py:21 +#: shiftings/organizations/templates/organizations/organization_shifts.html:66 +#: shiftings/shifts/models/shift.py:21 msgid "Event" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:68 +#: shiftings/organizations/templates/organizations/organization_shifts.html:68 msgid "Go to Event" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:74 +#: shiftings/organizations/templates/organizations/organization_shifts.html:74 #, python-format msgid "" "\n" @@ -2675,463 +1194,464 @@ msgid "" " " msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:94 +#: shiftings/organizations/templates/organizations/organization_shifts.html:94 msgid "No Events planned" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:102 +#: shiftings/organizations/templates/organizations/organization_shifts.html:102 msgid "Events disabled" msgstr "" -#: src/shiftings/organizations/templates/organizations/organization_shifts.html:117 +#: shiftings/organizations/templates/organizations/organization_shifts.html:117 msgid "Create Shift from Template" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/member_group.html:6 +#: shiftings/organizations/templates/organizations/template/member_group.html:6 msgid "Click to expand members" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:8 +#: shiftings/organizations/templates/organizations/template/org_info.html:8 msgid "Organization Overview" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:12 +#: shiftings/organizations/templates/organizations/template/org_info.html:12 msgid "Administrate Organization" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:14 +#: shiftings/organizations/templates/organizations/template/org_info.html:14 msgid "Organization Admin View" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:19 +#: shiftings/organizations/templates/organizations/template/org_info.html:19 msgid "Organization Settings" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:21 +#: shiftings/organizations/templates/organizations/template/org_info.html:21 msgid "Organization Settings View" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:35 +#: shiftings/organizations/templates/organizations/template/org_info.html:35 msgid "Send Mail to shift participants" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:41 +#: shiftings/organizations/templates/organizations/template/org_info.html:41 msgid "Edit Organization" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/org_info.html:47 +#: shiftings/organizations/templates/organizations/template/org_info.html:47 msgid "Update Shift Permissions" msgstr "" -#: src/shiftings/organizations/templates/organizations/template/organization.html:12 +#: shiftings/organizations/templates/organizations/template/organization.html:12 msgid "No Image available" msgstr "" -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:6 +#: shiftings/organizations/templates/organizations/user/claim_users.html:6 msgid "Imported Users" msgstr "" -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:14 +#: shiftings/organizations/templates/organizations/user/claim_users.html:14 msgid "Claimed" msgstr "" -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:36 +#: shiftings/organizations/templates/organizations/user/claim_users.html:36 msgid "Claim" msgstr "" -#: src/shiftings/organizations/templates/organizations/user/claim_users.html:43 +#: shiftings/organizations/templates/organizations/user/claim_users.html:43 msgid "Unclaim" msgstr "" -#: src/shiftings/organizations/views/membership.py:55 -#: src/shiftings/organizations/views/membership_type.py:57 +#: shiftings/organizations/views/membership.py:55 +#: shiftings/organizations/views/membership_type.py:57 msgid "Membership removed" msgstr "" -#: src/shiftings/organizations/views/organization.py:62 +#: shiftings/organizations/views/organization.py:62 #, python-brace-format msgid "{org} Overview" msgstr "" -#: src/shiftings/organizations/views/organization.py:89 +#: shiftings/organizations/views/organization.py:89 #, python-brace-format msgid "{org} Administration" msgstr "" -#: src/shiftings/organizations/views/organization.py:135 +#: shiftings/organizations/views/organization.py:135 #, python-brace-format msgid "{org} Settings" msgstr "" -#: src/shiftings/organizations/views/user.py:36 +#: shiftings/organizations/views/user.py:36 msgid "You can't claim users that are already claimed." msgstr "" -#: src/shiftings/shifts/forms/filters.py:12 +#: shiftings/shifts/forms/filters.py:12 msgid "Shifts I participate in" msgstr "" -#: src/shiftings/shifts/forms/filters.py:17 -#: src/shiftings/shifts/forms/filters.py:19 +#: shiftings/shifts/forms/filters.py:17 shiftings/shifts/forms/filters.py:19 msgid "Date YYYY-MM-DD" msgstr "" -#: src/shiftings/shifts/forms/filters.py:18 -#: src/shiftings/shifts/forms/filters.py:20 +#: shiftings/shifts/forms/filters.py:18 shiftings/shifts/forms/filters.py:20 msgid "Time HH:MM" msgstr "" -#: src/shiftings/shifts/forms/participant.py:28 +#: shiftings/shifts/forms/participant.py:28 #, python-brace-format msgid "User {user} is already registered for this shift." msgstr "" -#: src/shiftings/shifts/forms/participant.py:32 +#: shiftings/shifts/forms/participant.py:32 msgid "Organization Users" msgstr "" -#: src/shiftings/shifts/forms/participant.py:33 +#: shiftings/shifts/forms/participant.py:33 msgid "Other Users" msgstr "" -#: src/shiftings/shifts/forms/participant.py:34 +#: shiftings/shifts/forms/participant.py:34 msgid "" "To add users that do not belong to your organization please enter their " "username." msgstr "" -#: src/shiftings/shifts/forms/participant.py:65 +#: shiftings/shifts/forms/participant.py:65 msgid "Only select one type of user!" msgstr "" -#: src/shiftings/shifts/forms/participant.py:75 +#: shiftings/shifts/forms/participant.py:75 msgid "One of the user fields is required!" msgstr "" -#: src/shiftings/shifts/forms/participant.py:80 +#: shiftings/shifts/forms/participant.py:80 #, python-brace-format msgid "Cannot add {user} multiple times to this shift" msgstr "" -#: src/shiftings/shifts/forms/permission.py:36 -#: src/shiftings/shifts/forms/permission.py:90 +#: shiftings/shifts/forms/permission.py:36 +#: shiftings/shifts/forms/permission.py:90 #, python-brace-format msgid "" "Permission would have no effect. Permission for all Users is more extensive: " "{permission}" msgstr "" -#: src/shiftings/shifts/forms/permission.py:47 +#: shiftings/shifts/forms/permission.py:47 #, python-brace-format msgid "" "Permission would have no effect. There is an inherited more extensive " "permission: {referred_object} ({permission})" msgstr "" -#: src/shiftings/shifts/forms/permission.py:56 +#: shiftings/shifts/forms/permission.py:56 #, python-brace-format msgid "" "Permission would have no effect. There is an inherited more extensive " "permission for all Users: {referred_object} ({permission})" msgstr "" -#: src/shiftings/shifts/forms/permission.py:78 +#: shiftings/shifts/forms/permission.py:78 #, python-brace-format msgid "Can only set one permission for organization \"{organization}\"" msgstr "" -#: src/shiftings/shifts/forms/permission.py:80 +#: shiftings/shifts/forms/permission.py:80 msgid "Can only set one permission for all Users" msgstr "" -#: src/shiftings/shifts/forms/recurring.py:13 +#: shiftings/shifts/forms/recurring.py:13 msgid "Ordinal" msgstr "" -#: src/shiftings/shifts/forms/recurring.py:35 +#: shiftings/shifts/forms/recurring.py:35 msgid "Weekday can't be empty with the chosen time frame type." msgstr "" -#: src/shiftings/shifts/forms/recurring.py:37 +#: shiftings/shifts/forms/recurring.py:37 msgid "Month can't be empty with the chosen time frame type." msgstr "" -#: src/shiftings/shifts/forms/recurring.py:39 +#: shiftings/shifts/forms/recurring.py:39 msgid "You need to add a warning for weekend handling." msgstr "" -#: src/shiftings/shifts/forms/recurring.py:41 +#: shiftings/shifts/forms/recurring.py:41 msgid "You need to add a warning for holiday handling." msgstr "" -#: src/shiftings/shifts/forms/shift.py:38 -#: src/shiftings/shifts/forms/template.py:66 +#: shiftings/shifts/forms/shift.py:41 +msgid "End time must be after start time" +msgstr "" + +#: shiftings/shifts/forms/shift.py:46 shiftings/shifts/forms/template.py:66 #, python-brace-format msgid "Shift is too long, can at most be {max} long" msgstr "" -#: src/shiftings/shifts/forms/template.py:18 +#: shiftings/shifts/forms/template.py:18 msgid "Date" msgstr "" -#: src/shiftings/shifts/forms/template.py:18 +#: shiftings/shifts/forms/template.py:18 msgid "Date to create the template on" msgstr "" -#: src/shiftings/shifts/forms/template.py:38 -#: src/shiftings/shifts/models/template.py:22 +#: shiftings/shifts/forms/template.py:38 shiftings/shifts/models/template.py:22 msgid "Start Delay" msgstr "" -#: src/shiftings/shifts/forms/template.py:58 +#: shiftings/shifts/forms/template.py:40 shiftings/shifts/models/template.py:23 +#: shiftings/shifts/templates/shifts/recurring/list.html:11 +msgid "Duration" +msgstr "" + +#: shiftings/shifts/forms/template.py:58 msgid "Start Delay was None please reload and use the slider." msgstr "" -#: src/shiftings/shifts/forms/template.py:64 +#: shiftings/shifts/forms/template.py:64 msgid "Duration was None please reload and use the slider." msgstr "" -#: src/shiftings/shifts/forms/type.py:22 +#: shiftings/shifts/forms/type.py:22 msgid "Can't name a shift type \"System\"." msgstr "" -#: src/shiftings/shifts/models/base.py:13 -#: src/shiftings/shifts/models/template.py:19 -#: src/shiftings/shifts/templates/shifts/shift.html:39 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:18 +#: shiftings/shifts/models/base.py:13 shiftings/shifts/models/template.py:19 +#: shiftings/shifts/templates/shifts/shift.html:39 +#: shiftings/shifts/templates/shifts/template/template_form.html:18 msgid "Shift Type" msgstr "" -#: src/shiftings/shifts/models/base.py:16 +#: shiftings/shifts/models/base.py:16 msgid "Required Users" msgstr "" -#: src/shiftings/shifts/models/base.py:18 -#: src/shiftings/shifts/models/template.py:27 +#: shiftings/shifts/models/base.py:18 shiftings/shifts/models/template.py:27 msgid "A maximum of 32 users can be required" msgstr "" -#: src/shiftings/shifts/models/base.py:19 +#: shiftings/shifts/models/base.py:19 msgid "Maximum Users" msgstr "" -#: src/shiftings/shifts/models/base.py:21 -#: src/shiftings/shifts/models/template.py:30 +#: shiftings/shifts/models/base.py:21 shiftings/shifts/models/template.py:30 msgid "A maximum of 64 users can be present" msgstr "" -#: src/shiftings/shifts/models/base.py:23 -#: src/shiftings/shifts/models/template.py:32 -#: src/shiftings/shifts/templates/shifts/shift.html:79 -#: src/shiftings/shifts/templates/shifts/template/template.html:26 +#: shiftings/shifts/models/base.py:23 shiftings/shifts/models/template.py:32 +#: shiftings/shifts/templates/shifts/shift.html:79 +#: shiftings/shifts/templates/shifts/template/template.html:26 msgid "Additional Infos" msgstr "" -#: src/shiftings/shifts/models/participant.py:16 +#: shiftings/shifts/models/participant.py:16 msgid "Display Name is optional, and will be shown instead of the username" msgstr "" -#: src/shiftings/shifts/models/participant.py:17 +#: shiftings/shifts/models/participant.py:17 msgid "Participation confirmed" msgstr "" -#: src/shiftings/shifts/models/participant.py:18 +#: shiftings/shifts/models/participant.py:18 msgid "" "Whether the participant has taken part in the shift or not. Default: False" msgstr "" -#: src/shiftings/shifts/models/permission.py:18 +#: shiftings/shifts/models/permission.py:18 msgid "No Permission" msgstr "" -#: src/shiftings/shifts/models/permission.py:19 +#: shiftings/shifts/models/permission.py:19 msgid "See Shift exists" msgstr "" -#: src/shiftings/shifts/models/permission.py:20 +#: shiftings/shifts/models/permission.py:20 msgid "See Shift Details" msgstr "" -#: src/shiftings/shifts/models/permission.py:21 +#: shiftings/shifts/models/permission.py:21 msgid "See Shift Participants" msgstr "" -#: src/shiftings/shifts/models/permission.py:22 +#: shiftings/shifts/models/permission.py:22 msgid "Participate" msgstr "" -#: src/shiftings/shifts/models/permission.py:80 +#: shiftings/shifts/models/permission.py:80 msgid "Permission Type" msgstr "" -#: src/shiftings/shifts/models/permission.py:82 +#: shiftings/shifts/models/permission.py:82 msgid "Affected Organization" msgstr "" -#: src/shiftings/shifts/models/recurring.py:26 +#: shiftings/shifts/models/recurring.py:26 msgid "ignore" msgstr "" -#: src/shiftings/shifts/models/recurring.py:27 +#: shiftings/shifts/models/recurring.py:27 msgid "cancel" msgstr "" -#: src/shiftings/shifts/models/recurring.py:28 +#: shiftings/shifts/models/recurring.py:28 msgid "show warning" msgstr "" -#: src/shiftings/shifts/models/recurring.py:36 +#: shiftings/shifts/models/recurring.py:36 msgid "Timeframe Type" msgstr "" -#: src/shiftings/shifts/models/recurring.py:41 -#: src/shiftings/shifts/templates/shifts/recurring/list.html:10 -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:53 +#: shiftings/shifts/models/recurring.py:41 +#: shiftings/shifts/templates/shifts/recurring/list.html:10 +#: shiftings/shifts/templates/shifts/recurring/shift.html:53 msgid "First Occurrence" msgstr "" -#: src/shiftings/shifts/models/recurring.py:42 +#: shiftings/shifts/models/recurring.py:42 msgid "" "Choose a minimum day for first occurrence and the system will automatically " "choose the next applicable day." msgstr "" -#: src/shiftings/shifts/models/recurring.py:46 +#: shiftings/shifts/models/recurring.py:46 msgid "Auto create days" msgstr "" -#: src/shiftings/shifts/models/recurring.py:46 +#: shiftings/shifts/models/recurring.py:46 msgid "How many days in advance should the shifts be created?" msgstr "" -#: src/shiftings/shifts/models/recurring.py:48 +#: shiftings/shifts/models/recurring.py:48 msgid "Shift Template" msgstr "" -#: src/shiftings/shifts/models/recurring.py:52 +#: shiftings/shifts/models/recurring.py:52 msgid "Weekend problem handling" msgstr "" -#: src/shiftings/shifts/models/recurring.py:54 +#: shiftings/shifts/models/recurring.py:54 msgid "Warning text for weekend" msgstr "" -#: src/shiftings/shifts/models/recurring.py:59 +#: shiftings/shifts/models/recurring.py:59 msgid "Holidays problem handling" msgstr "" -#: src/shiftings/shifts/models/recurring.py:61 +#: shiftings/shifts/models/recurring.py:61 msgid "Warning text for holidays" msgstr "" -#: src/shiftings/shifts/models/recurring.py:67 +#: shiftings/shifts/models/recurring.py:67 msgid "Manually Disabled" msgstr "" -#: src/shiftings/shifts/models/recurring.py:88 +#: shiftings/shifts/models/recurring.py:88 msgid "Nth" msgstr "" -#: src/shiftings/shifts/models/recurring.py:89 +#: shiftings/shifts/models/recurring.py:89 msgid "[weekday]" msgstr "" -#: src/shiftings/shifts/models/recurring.py:90 +#: shiftings/shifts/models/recurring.py:90 msgid "[month]" msgstr "" -#: src/shiftings/shifts/models/shift.py:24 +#: shiftings/shifts/models/shift.py:24 msgid "Start Date and Time" msgstr "" -#: src/shiftings/shifts/models/shift.py:25 +#: shiftings/shifts/models/shift.py:25 msgid "End Date and Time" msgstr "" -#: src/shiftings/shifts/models/shift.py:27 -#: src/shiftings/shifts/templates/shifts/template/shift.html:13 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:21 +#: shiftings/shifts/models/shift.py:27 +#: shiftings/shifts/templates/shifts/template/shift.html:13 +#: shiftings/shifts/templates/shifts/template/template_form.html:21 msgid "Users" msgstr "" -#: src/shiftings/shifts/models/shift.py:30 +#: shiftings/shifts/models/shift.py:30 msgid "Locked for Participation" msgstr "" -#: src/shiftings/shifts/models/shift.py:31 -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:58 -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:64 +#: shiftings/shifts/models/shift.py:31 +#: shiftings/shifts/templates/shifts/recurring/shift.html:58 +#: shiftings/shifts/templates/shifts/recurring/shift.html:64 msgid "Warning" msgstr "" -#: src/shiftings/shifts/models/shift.py:35 +#: shiftings/shifts/models/shift.py:35 msgid "Created by Recurring Shift" msgstr "" -#: src/shiftings/shifts/models/shift.py:37 -#: src/shiftings/shifts/templates/shifts/shift.html:53 +#: shiftings/shifts/models/shift.py:37 +#: shiftings/shifts/templates/shifts/shift.html:53 msgid "Created" msgstr "" -#: src/shiftings/shifts/models/shift.py:38 -#: src/shiftings/shifts/templates/shifts/shift.html:55 +#: shiftings/shifts/models/shift.py:38 +#: shiftings/shifts/templates/shifts/shift.html:55 msgid "Last Modified" msgstr "" -#: src/shiftings/shifts/models/shift.py:54 +#: shiftings/shifts/models/shift.py:54 msgid "Organization and Event Organization must be identical." msgstr "" -#: src/shiftings/shifts/models/shift.py:57 +#: shiftings/shifts/models/shift.py:57 #, python-brace-format msgid "Shift {name} on {time}" msgstr "" -#: src/shiftings/shifts/models/shift.py:65 +#: shiftings/shifts/models/shift.py:65 #, python-brace-format msgid "{name} from {start} to {end} " msgstr "" -#: src/shiftings/shifts/models/shift.py:72 +#: shiftings/shifts/models/shift.py:72 #, python-brace-format msgid "{start} to {end_time} on {end_date} " msgstr "" -#: src/shiftings/shifts/models/shift.py:76 +#: shiftings/shifts/models/shift.py:76 #, python-brace-format msgid "{start} to {end_time}" msgstr "" -#: src/shiftings/shifts/models/summary.py:11 +#: shiftings/shifts/models/summary.py:11 msgid "\"Other\" Shift Type Group Name" msgstr "" -#: src/shiftings/shifts/models/summary.py:14 +#: shiftings/shifts/models/summary.py:14 msgid "Default time range for summary" msgstr "" -#: src/shiftings/shifts/models/summary.py:25 +#: shiftings/shifts/models/summary.py:25 #, python-brace-format msgid "Organization summary settings of {organization}" msgstr "" -#: src/shiftings/shifts/models/template.py:16 +#: shiftings/shifts/models/template.py:16 msgid "Template Group" msgstr "" -#: src/shiftings/shifts/models/template.py:25 +#: shiftings/shifts/models/template.py:25 msgid "Required User" msgstr "" -#: src/shiftings/shifts/models/template.py:28 +#: shiftings/shifts/models/template.py:28 msgid "Maximum User" msgstr "" -#: src/shiftings/shifts/signals.py:26 +#: shiftings/shifts/signals.py:26 msgid "Could not find a valid first occurrence." msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:11 +#: shiftings/shifts/templates/shifts/create_shift.html:11 #, python-format msgid "" "\n" @@ -3139,7 +1659,7 @@ msgid "" " " msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:15 +#: shiftings/shifts/templates/shifts/create_shift.html:15 #, python-format msgid "" "\n" @@ -3147,70 +1667,70 @@ msgid "" " " msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:22 -#: src/shiftings/shifts/templates/shifts/create_shift.html:83 -#: src/shiftings/shifts/templates/shifts/recurring/form.html:23 -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:19 -#: src/shiftings/templates/generic/create_or_update.html:18 -#: src/shiftings/templates/generic/form_card.html:8 +#: shiftings/shifts/templates/shifts/create_shift.html:22 +#: shiftings/shifts/templates/shifts/create_shift.html:93 +#: shiftings/shifts/templates/shifts/recurring/form.html:23 +#: shiftings/shifts/templates/shifts/recurring/shift.html:19 +#: shiftings/templates/generic/create_or_update.html:18 +#: shiftings/templates/generic/form_card.html:8 msgid "Create" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:24 +#: shiftings/shifts/templates/shifts/create_shift.html:24 msgid "Update" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:26 -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:34 -#: src/shiftings/templates/generic/create_or_update.html:22 +#: shiftings/shifts/templates/shifts/create_shift.html:26 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:34 +#: shiftings/templates/generic/create_or_update.html:22 msgid "Back" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:47 +#: shiftings/shifts/templates/shifts/create_shift.html:57 msgid "Shift starting time" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:48 +#: shiftings/shifts/templates/shifts/create_shift.html:58 msgctxt "Shift time" msgid "Start Time" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:52 +#: shiftings/shifts/templates/shifts/create_shift.html:62 msgid "Shift end time" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:53 +#: shiftings/shifts/templates/shifts/create_shift.html:63 msgctxt "Shift time" msgid "End Time" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:59 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:24 +#: shiftings/shifts/templates/shifts/create_shift.html:69 +#: shiftings/shifts/templates/shifts/template/template_form.html:24 msgid "Required number of users to fill this shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:60 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:25 +#: shiftings/shifts/templates/shifts/create_shift.html:70 +#: shiftings/shifts/templates/shifts/template/template_form.html:25 msgctxt "Shift users" msgid "Required" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:64 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:29 +#: shiftings/shifts/templates/shifts/create_shift.html:74 +#: shiftings/shifts/templates/shifts/template/template_form.html:29 msgid "Maximum number of users allowed in this shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:65 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:30 +#: shiftings/shifts/templates/shifts/create_shift.html:75 +#: shiftings/shifts/templates/shifts/template/template_form.html:30 msgctxt "Shift users" msgid "Maximum" msgstr "" -#: src/shiftings/shifts/templates/shifts/create_shift.html:76 +#: shiftings/shifts/templates/shifts/create_shift.html:86 msgid "Create from Template" msgstr "" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:17 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:17 #, python-format msgid "" "\n" @@ -3219,39 +1739,39 @@ msgid "" " " msgstr "" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:26 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:26 msgid "Edit Shift Permissions for" msgstr "" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:38 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:38 msgid "How does it work?" msgstr "" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:53 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:53 msgid "Inherited Permissions" msgstr "" -#: src/shiftings/shifts/templates/shifts/edit_participation_permissions.html:65 +#: shiftings/shifts/templates/shifts/edit_participation_permissions.html:65 msgid "All Users" msgstr "" -#: src/shiftings/shifts/templates/shifts/edit_templates.html:21 +#: shiftings/shifts/templates/shifts/edit_templates.html:21 msgid "Edit Templates for" msgstr "" -#: src/shiftings/shifts/templates/shifts/edit_templates.html:29 +#: shiftings/shifts/templates/shifts/edit_templates.html:29 msgid "Cancel" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/form.html:33 +#: shiftings/shifts/templates/shifts/recurring/form.html:33 msgid "Recurring Time Frame" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/form.html:42 +#: shiftings/shifts/templates/shifts/recurring/form.html:42 msgid "Shift Information" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:16 +#: shiftings/shifts/templates/shifts/recurring/shift.html:16 #, python-format msgid "" "\n" @@ -3259,11 +1779,11 @@ msgid "" " " msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:37 +#: shiftings/shifts/templates/shifts/recurring/shift.html:37 msgid "Edit Recurring Shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:44 +#: shiftings/shifts/templates/shifts/recurring/shift.html:44 #, python-format msgid "" "\n" @@ -3272,7 +1792,7 @@ msgid "" " " msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:48 +#: shiftings/shifts/templates/shifts/recurring/shift.html:48 #, python-format msgid "" "\n" @@ -3280,44 +1800,44 @@ msgid "" " " msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:55 +#: shiftings/shifts/templates/shifts/recurring/shift.html:55 msgid "Weekend Handling" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:61 +#: shiftings/shifts/templates/shifts/recurring/shift.html:61 msgid "Holiday Handling" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:75 +#: shiftings/shifts/templates/shifts/recurring/shift.html:75 msgid "Upcoming recurring Shifts" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:86 +#: shiftings/shifts/templates/shifts/recurring/shift.html:86 msgid "No upcoming created Shifts" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:93 +#: shiftings/shifts/templates/shifts/recurring/shift.html:93 msgid "Passed recurring Shifts" msgstr "" -#: src/shiftings/shifts/templates/shifts/recurring/shift.html:104 +#: shiftings/shifts/templates/shifts/recurring/shift.html:104 msgid "No created Shifts have passed yet" msgstr "" -#: src/shiftings/shifts/templates/shifts/shift.html:16 -#: src/shiftings/shifts/views/shift.py:78 +#: shiftings/shifts/templates/shifts/shift.html:16 +#: shiftings/shifts/views/shift.py:78 msgid "Edit Shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/shift.html:22 +#: shiftings/shifts/templates/shifts/shift.html:22 msgid "Delete Shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/shift.html:28 +#: shiftings/shifts/templates/shifts/shift.html:28 msgid "Edit Permissions" msgstr "" -#: src/shiftings/shifts/templates/shifts/shift.html:57 +#: shiftings/shifts/templates/shifts/shift.html:57 #, python-format msgid "" "\n" @@ -3325,219 +1845,219 @@ msgid "" " " msgstr "" -#: src/shiftings/shifts/templates/shifts/shift.html:66 -#: src/shiftings/shifts/templates/shifts/template/shift_card.html:13 +#: shiftings/shifts/templates/shifts/shift.html:66 +#: shiftings/shifts/templates/shifts/template/shift_card.html:13 msgid "Shift Participants" msgstr "" -#: src/shiftings/shifts/templates/shifts/shift.html:71 +#: shiftings/shifts/templates/shifts/shift.html:71 msgid "Add participant" msgstr "" -#: src/shiftings/shifts/templates/shifts/shift.html:84 +#: shiftings/shifts/templates/shifts/shift.html:84 msgid "Nothing to consider." msgstr "" -#: src/shiftings/shifts/templates/shifts/shift_participants.html:11 +#: shiftings/shifts/templates/shifts/shift_participants.html:11 msgid "" "This shift is over, do you really want/need to add yourself as participant?" msgstr "" -#: src/shiftings/shifts/templates/shifts/shift_url_filters.html:8 +#: shiftings/shifts/templates/shifts/shift_url_filters.html:8 msgid "Toggle Shift Filters" msgstr "" -#: src/shiftings/shifts/templates/shifts/summary/summary.html:6 +#: shiftings/shifts/templates/shifts/summary/summary.html:6 msgid "Shift Summary" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/group.html:17 +#: shiftings/shifts/templates/shifts/template/group.html:17 msgid "Delete Template" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/group.html:21 +#: shiftings/shifts/templates/shifts/template/group.html:21 msgid "Edit Template" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/group.html:25 -#: src/shiftings/shifts/templates/shifts/template/group_template.html:13 +#: shiftings/shifts/templates/shifts/template/group.html:25 +#: shiftings/shifts/templates/shifts/template/group_template.html:13 msgid "Edit Shifts" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/group.html:31 +#: shiftings/shifts/templates/shifts/template/group.html:31 msgid "Edit Template Permissions" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/group.html:70 +#: shiftings/shifts/templates/shifts/template/group.html:70 msgid "Create Shift Templates" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/group_template.html:9 +#: shiftings/shifts/templates/shifts/template/group_template.html:9 msgid "Edit Shifts Template" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/member_shift_summary.html:3 +#: shiftings/shifts/templates/shifts/template/member_shift_summary.html:3 msgid "Member" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/member_shift_summary.html:10 +#: shiftings/shifts/templates/shifts/template/member_shift_summary.html:10 msgid "Total" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/participation_permission_form.html:9 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:10 +#: shiftings/shifts/templates/shifts/template/participation_permission_form.html:9 +#: shiftings/shifts/templates/shifts/template/template_form.html:10 msgid "Remove Shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/participation_permission_form.html:12 -#: src/shiftings/shifts/templates/shifts/template/template_form.html:13 +#: shiftings/shifts/templates/shifts/template/participation_permission_form.html:12 +#: shiftings/shifts/templates/shifts/template/template_form.html:13 msgid "Restore Shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/select_org.html:3 +#: shiftings/shifts/templates/shifts/template/select_org.html:3 msgid "Select Organization to create Shift for" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift.html:5 +#: shiftings/shifts/templates/shifts/template/shift.html:5 msgid "Go to Shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:5 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:5 msgid "Filters" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:8 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:8 msgid "Reset Filter" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:12 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:12 msgid "Apply Filter" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:15 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:15 msgid "Parameters" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:29 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:29 msgid "Select Organizations" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:47 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:47 msgid "Shift starts after" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:52 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:52 msgid "Shift ends before" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:65 +#: shiftings/shifts/templates/shifts/template/shift_url_filter_form.html:65 msgid "Select Events" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/slots_display.html:4 +#: shiftings/shifts/templates/shifts/template/slots_display.html:4 msgid "Required to carry out this Shift" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/slots_display.html:6 +#: shiftings/shifts/templates/shifts/template/slots_display.html:6 msgid "Open Shift Slot" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/slots_display.html:32 +#: shiftings/shifts/templates/shifts/template/slots_display.html:32 msgid "Free" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:20 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:20 msgid "Required" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:23 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:23 msgid "Confirmed" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:29 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:29 msgid "Participating" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:34 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:34 msgid "Full" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:40 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:40 msgid "Additional Users required" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/small_shift_display.html:42 +#: shiftings/shifts/templates/shifts/template/small_shift_display.html:42 msgid "Additional Slots available" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/template.html:11 +#: shiftings/shifts/templates/shifts/template/template.html:11 msgid "Calendar Background Color" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/template.html:17 +#: shiftings/shifts/templates/shifts/template/template.html:17 msgid "Required Users/Max Users" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/template.html:21 +#: shiftings/shifts/templates/shifts/template/template.html:21 msgid "Unlimited" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/template_form.html:36 +#: shiftings/shifts/templates/shifts/template/template_form.html:36 msgid "Delay to starting time" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/template_form.html:37 +#: shiftings/shifts/templates/shifts/template/template_form.html:37 msgctxt "Shift time" msgid "Start Delay" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/template_form.html:41 +#: shiftings/shifts/templates/shifts/template/template_form.html:41 msgid "Shift Duration" msgstr "" -#: src/shiftings/shifts/templates/shifts/template/template_form.html:42 +#: shiftings/shifts/templates/shifts/template/template_form.html:42 msgctxt "Shift time" msgid "Duration" msgstr "" -#: src/shiftings/shifts/templates/shifts/type_group/list.html:8 +#: shiftings/shifts/templates/shifts/type_group/list.html:8 msgid "Actions" msgstr "" -#: src/shiftings/shifts/templatetags/shifts.py:90 +#: shiftings/shifts/templatetags/shifts.py:90 #, python-brace-format msgid "Days + {delta_days}" msgstr "" -#: src/shiftings/shifts/utils/time_frame.py:17 +#: shiftings/shifts/utils/time_frame.py:17 msgid "Nth [weekday] of each month" msgstr "" -#: src/shiftings/shifts/utils/time_frame.py:18 +#: shiftings/shifts/utils/time_frame.py:18 msgid "Nth day of each month" msgstr "" -#: src/shiftings/shifts/utils/time_frame.py:19 +#: shiftings/shifts/utils/time_frame.py:19 msgid "every Nth [weekday]" msgstr "" -#: src/shiftings/shifts/utils/time_frame.py:20 +#: shiftings/shifts/utils/time_frame.py:20 msgid "Nth workday of each month" msgstr "" -#: src/shiftings/shifts/utils/time_frame.py:21 +#: shiftings/shifts/utils/time_frame.py:21 msgid "Nth day of [month]" msgstr "" -#: src/shiftings/shifts/utils/time_frame.py:22 +#: shiftings/shifts/utils/time_frame.py:22 msgid "Nth workday of [month]" msgstr "" -#: src/shiftings/shifts/views/participant.py:42 +#: shiftings/shifts/views/participant.py:42 msgid "This shift is over, do you really want/need to add a participant?" msgstr "" -#: src/shiftings/shifts/views/permission.py:50 +#: shiftings/shifts/views/permission.py:50 #, python-brace-format msgid "" "

Any Permission present will be applied to all Shifts belonging to " @@ -3547,35 +2067,36 @@ msgid "" "organizations.

" msgstr "" -#: src/shiftings/shifts/views/recurring.py:85 +#: shiftings/shifts/views/recurring.py:85 msgid "Forbidden Method GET" msgstr "" -#: src/shiftings/shifts/views/recurring.py:98 +#: shiftings/shifts/views/recurring.py:98 +#, python-brace-format msgid "Created Shifts on {}" msgstr "" -#: src/shiftings/shifts/views/recurring.py:102 +#: shiftings/shifts/views/recurring.py:102 msgid "Error while creating shifts. Invalid Date" msgstr "" -#: src/shiftings/shifts/views/shift.py:164 +#: shiftings/shifts/views/shift.py:164 msgid "Unable to delete past shifts." msgstr "" -#: src/shiftings/templates/403.html:6 +#: shiftings/templates/403.html:6 msgid "You don't have the permission to do that!" msgstr "" -#: src/shiftings/templates/404.html:6 +#: shiftings/templates/404.html:6 msgid "The site you requested was not found." msgstr "" -#: src/shiftings/templates/500.html:6 +#: shiftings/templates/500.html:6 msgid "A server error occured the admins have been notified" msgstr "" -#: src/shiftings/templates/generic/create_or_update.html:5 +#: shiftings/templates/generic/create_or_update.html:5 #, python-format msgid "" "\n" @@ -3583,7 +2104,7 @@ msgid "" " " msgstr "" -#: src/shiftings/templates/generic/create_or_update.html:9 +#: shiftings/templates/generic/create_or_update.html:9 #, python-format msgid "" "\n" @@ -3591,7 +2112,7 @@ msgid "" " " msgstr "" -#: src/shiftings/templates/generic/delete.html:6 +#: shiftings/templates/generic/delete.html:6 #, python-format msgid "" "\n" @@ -3599,53 +2120,66 @@ msgid "" " " msgstr "" -#: src/shiftings/templates/generic/modular_search.html:4 +#: shiftings/templates/generic/delete.html:9 +msgid "Yes" +msgstr "" + +#: shiftings/templates/generic/delete.html:11 +msgid "No" +msgstr "" + +#: shiftings/templates/generic/modular_search.html:4 msgid "Filter by Name" msgstr "" -#: src/shiftings/templates/generic/modular_search.html:6 +#: shiftings/templates/generic/modular_search.html:6 msgid "submit search" msgstr "" -#: src/shiftings/templates/language.html:16 +#: shiftings/templates/language.html:16 msgid "Language" msgstr "" -#: src/shiftings/templates/language.html:21 +#: shiftings/templates/language.html:21 msgid "German" msgstr "" -#: src/shiftings/templates/language.html:24 +#: shiftings/templates/language.html:24 msgid "English" msgstr "" -#: src/shiftings/templates/month_view.html:2 +#: shiftings/templates/local/gdpr_template.sample.html:29 +#: shiftings/templates/template/remove_modal.html:39 +#: shiftings/templates/template/simple_display_modal.html:15 +#: shiftings/templates/template/simple_form_modal.html:22 +msgid "Close" +msgstr "" + +#: shiftings/templates/month_view.html:2 msgid "M" msgstr "" -#: src/shiftings/templates/month_view.html:3 -#: src/shiftings/templates/month_view.html:5 +#: shiftings/templates/month_view.html:3 shiftings/templates/month_view.html:5 msgid "T" msgstr "" -#: src/shiftings/templates/month_view.html:4 +#: shiftings/templates/month_view.html:4 msgid "W" msgstr "" -#: src/shiftings/templates/month_view.html:6 +#: shiftings/templates/month_view.html:6 msgid "F" msgstr "" -#: src/shiftings/templates/month_view.html:7 -#: src/shiftings/templates/month_view.html:8 +#: shiftings/templates/month_view.html:7 shiftings/templates/month_view.html:8 msgid "S" msgstr "" -#: src/shiftings/templates/switch_user.html:5 +#: shiftings/templates/switch_user.html:5 msgid "Switch User" msgstr "" -#: src/shiftings/templates/template/remove_modal.html:12 +#: shiftings/templates/template/remove_modal.html:12 #, python-format msgid "" "\n" @@ -3654,7 +2188,7 @@ msgid "" " " msgstr "" -#: src/shiftings/templates/template/remove_modal.html:16 +#: shiftings/templates/template/remove_modal.html:16 msgid "" "\n" " Removing Dict[str, Any]: password = cleaned_data['password'] confirm_password = cleaned_data['confirm_password'] if password != confirm_password: - self.add_error('password', _('Please enter matching passwords')) - self.add_error('confirm_password', _('Please enter matching passwords')) - + self.add_error('password', _('Passwords do not match.')) + self.add_error('confirm_password', _('Passwords do not match.')) + # validate password using Django's built-in validators try: validate_password(password, user=None, password_validators=get_password_validators(AUTH_PASSWORD_VALIDATORS)) @@ -54,7 +54,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.fields['first_name'].disabled = True self.fields['last_name'].disabled = True self.fields['email'].disabled = True - + def clean(self) -> Optional[dict[str, Any]]: cleaned_data = super().clean() if hasattr(self.instance, 'ldap_user'): @@ -62,5 +62,5 @@ def clean(self) -> Optional[dict[str, Any]]: if (cleaned_data.get('first_name') != self.instance.first_name or cleaned_data.get('last_name') != self.instance.last_name or cleaned_data.get('email') != self.instance.email): - raise forms.ValidationError(_('Cannot change first name, last name or email for LDAP users.')) + raise forms.ValidationError(_('Cannot change first name, last name or email for LDAP users. If you want to change these fields and/or see this message during normal usage, please contact the administrator via email (shiftings@hadiko.de) or issue tracker (https://github.com/hadiko/shiftings/issues).')) return cleaned_data diff --git a/src/shiftings/accounts/migrations/0002_user_theme_preference.py b/src/shiftings/accounts/migrations/0002_user_theme_preference.py new file mode 100644 index 0000000..c61465e --- /dev/null +++ b/src/shiftings/accounts/migrations/0002_user_theme_preference.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-04-17 00:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='theme_preference', + field=models.CharField(choices=[('light', 'Light'), ('dark', 'Dark'), ('auto', 'Auto')], default='auto', max_length=5, verbose_name='Theme Preference'), + ), + ] diff --git a/src/shiftings/accounts/migrations/0003_alter_user_options_failedtokenattempt_tokenusagelog.py b/src/shiftings/accounts/migrations/0003_alter_user_options_failedtokenattempt_tokenusagelog.py new file mode 100644 index 0000000..2876eee --- /dev/null +++ b/src/shiftings/accounts/migrations/0003_alter_user_options_failedtokenattempt_tokenusagelog.py @@ -0,0 +1,53 @@ +# Generated by Django 6.0.2 on 2026-04-18 17:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0002_user_theme_preference'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'default_permissions': (), 'ordering': ['display_name', 'username']}, + ), + migrations.CreateModel( + name='FailedTokenAttempt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(blank=True, help_text='User ID if identifiable', null=True)), + ('ip_address', models.GenericIPAddressField(help_text='IP address of failed attempt')), + ('reason', models.CharField(choices=[('invalid_token', 'Invalid Token'), ('expired_token', 'Expired Token'), ('invalid_uid', 'Invalid User ID'), ('user_not_found', 'User Not Found'), ('already_used', 'Token Already Used'), ('other', 'Other')], default='other', max_length=20)), + ('attempted_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Failed Token Attempt', + 'verbose_name_plural': 'Failed Token Attempts', + 'ordering': ['-attempted_at'], + 'default_permissions': (), + 'indexes': [models.Index(fields=['ip_address', 'attempted_at'], name='accounts_fa_ip_addr_44323c_idx'), models.Index(fields=['user_id', 'attempted_at'], name='accounts_fa_user_id_e177ff_idx')], + }, + ), + migrations.CreateModel( + name='TokenUsageLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(help_text='User ID (allows tracking even after user deletion)')), + ('token_hash', models.CharField(help_text='SHA256 hash of the token for secure storage', max_length=255, unique=True)), + ('token_type', models.CharField(choices=[('email_confirm', 'Email Confirmation'), ('password_reset', 'Password Reset')], help_text='Type of token', max_length=20)), + ('used_at', models.DateTimeField(auto_now_add=True, help_text='When token was used')), + ('ip_address', models.GenericIPAddressField(blank=True, help_text='IP address that used token', null=True)), + ('user_agent', models.TextField(blank=True, help_text='User agent that used token', null=True)), + ], + options={ + 'verbose_name': 'Token Usage Log', + 'verbose_name_plural': 'Token Usage Logs', + 'ordering': ['-used_at'], + 'default_permissions': (), + 'indexes': [models.Index(fields=['token_hash'], name='accounts_to_token_h_d6bccb_idx'), models.Index(fields=['user_id', 'token_type'], name='accounts_to_user_id_45f3d7_idx'), models.Index(fields=['used_at'], name='accounts_to_used_at_293c2f_idx')], + }, + ), + ] diff --git a/src/shiftings/accounts/models/__init__.py b/src/shiftings/accounts/models/__init__.py index 3ececc2..6ad0306 100644 --- a/src/shiftings/accounts/models/__init__.py +++ b/src/shiftings/accounts/models/__init__.py @@ -1 +1,2 @@ +from .token_usage import FailedTokenAttempt, TokenUsageLog from .user import BaseUser, User diff --git a/src/shiftings/accounts/models/token_usage.py b/src/shiftings/accounts/models/token_usage.py new file mode 100644 index 0000000..b5a2dd4 --- /dev/null +++ b/src/shiftings/accounts/models/token_usage.py @@ -0,0 +1,74 @@ +"""Token usage tracking for one-time-use token enforcement.""" +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class TokenUsageLog(models.Model): + """Track used tokens to prevent replay attacks.""" + + class TokenType(models.TextChoices): + EMAIL_CONFIRM = 'email_confirm', _('Email Confirmation') + PASSWORD_RESET = 'password_reset', _('Password Reset') + + user_id = models.IntegerField(help_text=_('User ID (allows tracking even after user deletion)')) + token_hash = models.CharField( + max_length=255, + unique=True, + help_text=_('SHA256 hash of the token for secure storage') + ) + token_type = models.CharField( + max_length=20, + choices=TokenType.choices, + help_text=_('Type of token') + ) + used_at = models.DateTimeField(auto_now_add=True, help_text=_('When token was used')) + ip_address = models.GenericIPAddressField(null=True, blank=True, help_text=_('IP address that used token')) + user_agent = models.TextField(null=True, blank=True, help_text=_('User agent that used token')) + + class Meta: + ordering = ['-used_at'] + indexes = [ + models.Index(fields=['token_hash']), + models.Index(fields=['user_id', 'token_type']), + models.Index(fields=['used_at']), + ] + verbose_name = _('Token Usage Log') + verbose_name_plural = _('Token Usage Logs') + default_permissions = () + + def __str__(self): + return f'{self.get_token_type_display()} - User {self.user_id} at {self.used_at}' + + +class FailedTokenAttempt(models.Model): + """Track failed token validation attempts for rate limiting.""" + + class FailureReason(models.TextChoices): + INVALID_TOKEN = 'invalid_token', _('Invalid Token') + EXPIRED_TOKEN = 'expired_token', _('Expired Token') + INVALID_UID = 'invalid_uid', _('Invalid User ID') + USER_NOT_FOUND = 'user_not_found', _('User Not Found') + ALREADY_USED = 'already_used', _('Token Already Used') + OTHER = 'other', _('Other') + + user_id = models.IntegerField(null=True, blank=True, help_text=_('User ID if identifiable')) + ip_address = models.GenericIPAddressField(help_text=_('IP address of failed attempt')) + reason = models.CharField( + max_length=20, + choices=FailureReason.choices, + default=FailureReason.OTHER + ) + attempted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-attempted_at'] + indexes = [ + models.Index(fields=['ip_address', 'attempted_at']), + models.Index(fields=['user_id', 'attempted_at']), + ] + verbose_name = _('Failed Token Attempt') + verbose_name_plural = _('Failed Token Attempts') + default_permissions = () + + def __str__(self): + return f'{self.get_reason_display()} - {self.ip_address} at {self.attempted_at}' diff --git a/src/shiftings/accounts/models/user.py b/src/shiftings/accounts/models/user.py index 86d149c..806a928 100644 --- a/src/shiftings/accounts/models/user.py +++ b/src/shiftings/accounts/models/user.py @@ -29,12 +29,21 @@ def display(self) -> str: class User(BaseUser): + class ThemePreference(models.TextChoices): + LIGHT = 'light', _('Light') + DARK = 'dark', _('Dark') + AUTO = 'auto', _('Auto') + display_name = models.CharField(max_length=150, verbose_name=_('Display Name'), null=True, blank=True) phone_number = PhoneNumberField(verbose_name=_('Telephone Number'), blank=True, null=True) + theme_preference = models.CharField(max_length=5, + choices=ThemePreference.choices, + default=ThemePreference.AUTO, + verbose_name=_('Theme Preference')) class Meta: default_permissions = () - ordering = ['username'] + ordering = ['display_name', 'username'] def __str__(self): return self.display diff --git a/src/shiftings/accounts/security/__init__.py b/src/shiftings/accounts/security/__init__.py new file mode 100644 index 0000000..d0e9692 --- /dev/null +++ b/src/shiftings/accounts/security/__init__.py @@ -0,0 +1,4 @@ +"""Security utilities for accounts app.""" +from .token_validator import TokenSecurityValidator + +__all__ = ['TokenSecurityValidator'] diff --git a/src/shiftings/accounts/security/token_validator.py b/src/shiftings/accounts/security/token_validator.py new file mode 100644 index 0000000..bae622f --- /dev/null +++ b/src/shiftings/accounts/security/token_validator.py @@ -0,0 +1,162 @@ +"""Token security utilities for validation and rate limiting.""" +import hashlib +import logging +from datetime import timedelta +from typing import Optional, Tuple + +from django.conf import settings +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils import timezone +from django.utils.encoding import force_bytes +from django.utils.translation import gettext_lazy as _ + +from shiftings.accounts.models import FailedTokenAttempt, TokenUsageLog + +logger = logging.getLogger(__name__) + + +class TokenSecurityValidator: + """Validate tokens with security checks including expiration and replay protection.""" + + def __init__(self, token_generator: PasswordResetTokenGenerator): + self.token_generator = token_generator + self.max_failed_attempts_per_ip = getattr(settings, 'TOKEN_SECURITY_MAX_FAILED_ATTEMPTS_PER_IP', 5) + self.failed_attempt_window_hours = getattr(settings, 'TOKEN_SECURITY_FAILED_ATTEMPT_WINDOW_HOURS', 1) + self.max_failed_attempts_per_user = getattr(settings, 'TOKEN_SECURITY_MAX_FAILED_ATTEMPTS_PER_USER', 10) + self.failed_attempt_window_user_hours = getattr(settings, 'TOKEN_SECURITY_FAILED_ATTEMPT_WINDOW_USER_HOURS', 24) + self.max_token_length = getattr(settings, 'TOKEN_SECURITY_MAX_TOKEN_LENGTH', 255) + + @staticmethod + def hash_token(token: str) -> str: + """Hash token for secure storage.""" + return hashlib.sha256(force_bytes(token)).hexdigest() + + def check_token_used(self, token: str) -> bool: + """Check if token has already been used.""" + token_hash = self.hash_token(token) + return TokenUsageLog.objects.filter(token_hash=token_hash).exists() + + def mark_token_used(self, token: str, user_id: int, token_type: str, + ip_address: Optional[str] = None, user_agent: Optional[str] = None) -> None: + """Mark token as used to prevent replay attacks.""" + token_hash = self.hash_token(token) + TokenUsageLog.objects.create( + user_id=user_id, + token_hash=token_hash, + token_type=token_type, + ip_address=ip_address, + user_agent=user_agent + ) + logger.info( + f'Token marked as used: user_id={user_id}, type={token_type}, ip={ip_address}' + ) + + def check_rate_limit(self, ip_address: Optional[str] = None, user_id: Optional[int] = None) -> Tuple[bool, Optional[str]]: + """Check if rate limits are exceeded. + + Returns: + (is_allowed, error_message) tuple + """ + if ip_address: + # Check IP-based rate limit + window_start = timezone.now() - timedelta(hours=self.failed_attempt_window_hours) + ip_attempts = FailedTokenAttempt.objects.filter( + ip_address=ip_address, + attempted_at__gte=window_start + ).count() + + if ip_attempts >= self.max_failed_attempts_per_ip: + logger.warning( + f'Rate limit exceeded for IP {ip_address}: ' + f'{ip_attempts} failed attempts in last {self.failed_attempt_window_hours} hour(s)' + ) + return False, _('Too many failed attempts. Please try again later.') + + if user_id: + # Check user-based rate limit + window_start = timezone.now() - timedelta(hours=self.failed_attempt_window_user_hours) + user_attempts = FailedTokenAttempt.objects.filter( + user_id=user_id, + attempted_at__gte=window_start + ).count() + + if user_attempts >= self.max_failed_attempts_per_user: + logger.warning( + f'Rate limit exceeded for user {user_id}: ' + f'{user_attempts} failed attempts in last {self.failed_attempt_window_user_hours} hour(s)' + ) + return False, _('Too many failed attempts. Please contact support.') + + return True, None + + def log_failed_attempt(self, reason: str, ip_address: Optional[str] = None, + user_id: Optional[int] = None) -> None: + """Log failed token attempt for rate limiting and security monitoring.""" + FailedTokenAttempt.objects.create( + user_id=user_id, + ip_address=ip_address, + reason=reason + ) + logger.warning( + f'Failed token validation: reason={reason}, user_id={user_id}, ip={ip_address}' + ) + + def validate_token_format(self, token: str) -> bool: + """Validate token format (basic length and character checks).""" + if not token or not isinstance(token, str): + return False + # Django tokens are typically 1-40 characters + if len(token) < 1 or len(token) > self.max_token_length: + return False + return True + + def validate_and_check_token(self, user, token: str, token_type: str, + ip_address: Optional[str] = None) -> Tuple[bool, Optional[str]]: + """Comprehensive token validation. + + Returns: + (is_valid, error_message) tuple + """ + # Check rate limit first + is_allowed, error_msg = self.check_rate_limit(ip_address=ip_address, user_id=user.pk if user else None) + if not is_allowed: + self.log_failed_attempt( + FailedTokenAttempt.FailureReason.OTHER, + ip_address=ip_address, + user_id=user.pk if user else None + ) + return False, error_msg + + # Validate token format + if not self.validate_token_format(token): + self.log_failed_attempt( + FailedTokenAttempt.FailureReason.INVALID_TOKEN, + ip_address=ip_address, + user_id=user.pk if user else None + ) + logger.error(f'Invalid token format for user {user.pk if user else "unknown"}') + return False, _('Invalid token format.') + + # Check if token has already been used + if self.check_token_used(token): + self.log_failed_attempt( + FailedTokenAttempt.FailureReason.ALREADY_USED, + ip_address=ip_address, + user_id=user.pk if user else None + ) + logger.warning(f'Token replay attempt for user {user.pk if user else "unknown"} from IP {ip_address}') + return False, _('This link has already been used.') + + # Check token validity with Django's token generator + if not self.token_generator.check_token(user, token): + # Check if it's an expired token + # Django's token generator considers tokens valid for PASSWORD_RESET_TIMEOUT (default 3 days) + self.log_failed_attempt( + FailedTokenAttempt.FailureReason.EXPIRED_TOKEN, + ip_address=ip_address, + user_id=user.pk + ) + logger.warning(f'Token validation failed (likely expired) for user {user.pk} from IP {ip_address}') + return False, _('This link has expired. Please request a new one.') + + return True, None diff --git a/src/shiftings/accounts/settings.py b/src/shiftings/accounts/settings.py index 119e4a6..75cdd21 100644 --- a/src/shiftings/accounts/settings.py +++ b/src/shiftings/accounts/settings.py @@ -3,3 +3,10 @@ REGISTRATION_FROM_EMAIL = None REGISTRATION_SUBJECT_PATH = Path(__file__).parent / Path('templates/registration/default_subject.tpl') REGISTRATION_BODY_PATH = Path(__file__).parent / Path('templates/registration/default_body.tpl') + +# Token security validation settings +TOKEN_SECURITY_MAX_FAILED_ATTEMPTS_PER_IP = 5 +TOKEN_SECURITY_FAILED_ATTEMPT_WINDOW_HOURS = 1 +TOKEN_SECURITY_MAX_FAILED_ATTEMPTS_PER_USER = 10 +TOKEN_SECURITY_FAILED_ATTEMPT_WINDOW_USER_HOURS = 24 +TOKEN_SECURITY_MAX_TOKEN_LENGTH = 255 diff --git a/src/shiftings/accounts/templates/accounts/confirm_email.html b/src/shiftings/accounts/templates/accounts/confirm_email.html index 5343e7b..cf78b41 100644 --- a/src/shiftings/accounts/templates/accounts/confirm_email.html +++ b/src/shiftings/accounts/templates/accounts/confirm_email.html @@ -9,4 +9,4 @@ -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/accounts/templates/accounts/login_form.html b/src/shiftings/accounts/templates/accounts/login_form.html index 22df72d..4f3d068 100644 --- a/src/shiftings/accounts/templates/accounts/login_form.html +++ b/src/shiftings/accounts/templates/accounts/login_form.html @@ -1,6 +1,6 @@ {% extends "generic/form_base.html" %} {% block form %} -
+ {% if feature.gdpr_template %} -
+ {% endif %} {% if is_local and feature.registration %} -
+ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/accounts/templates/accounts/login_multiple.html b/src/shiftings/accounts/templates/accounts/login_multiple.html index 69d3260..fb9b056 100644 --- a/src/shiftings/accounts/templates/accounts/login_multiple.html +++ b/src/shiftings/accounts/templates/accounts/login_multiple.html @@ -8,7 +8,7 @@

{% trans "Welcome to Shiftings" %}

- \ No newline at end of file +
diff --git a/src/shiftings/cal/templates/cal/template/day_calendar_xs.html b/src/shiftings/cal/templates/cal/template/day_calendar_xs.html index 4252685..438ec2a 100644 --- a/src/shiftings/cal/templates/cal/template/day_calendar_xs.html +++ b/src/shiftings/cal/templates/cal/template/day_calendar_xs.html @@ -6,7 +6,7 @@
{% endif %}
- +
@@ -91,8 +91,8 @@ {% endif %} {% empty %} - {% endfor %} @@ -113,4 +113,4 @@

{% trans "No Shifts found with these parameters or planned on this day" %} {% endif %} - \ No newline at end of file + diff --git a/src/shiftings/cal/templates/cal/week_calendar.html b/src/shiftings/cal/templates/cal/week_calendar.html index 8cfea8f..23d813b 100644 --- a/src/shiftings/cal/templates/cal/week_calendar.html +++ b/src/shiftings/cal/templates/cal/week_calendar.html @@ -4,19 +4,13 @@
- + {% trans "Previous Week" as previous_week_label %} + {% include "cal/week_nav_link.html" with href=last_url label=previous_week_label icon_class="fa-solid fa-angles-left" icon_before=True %}

{% trans "Week" %} {{ week|date:"W Y" }}

- + {% trans "Next Week" as next_week_label %} + {% include "cal/week_nav_link.html" with href=next_url label=next_week_label icon_class="fa-solid fa-angles-right" icon_before=False %}
@@ -74,4 +68,4 @@
{% trans day_name %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/cal/templates/cal/week_nav_link.html b/src/shiftings/cal/templates/cal/week_nav_link.html new file mode 100644 index 0000000..3f2f060 --- /dev/null +++ b/src/shiftings/cal/templates/cal/week_nav_link.html @@ -0,0 +1,9 @@ + diff --git a/src/shiftings/cal/views/day_calendar.py b/src/shiftings/cal/views/day_calendar.py index bcd6d1f..9920b41 100644 --- a/src/shiftings/cal/views/day_calendar.py +++ b/src/shiftings/cal/views/day_calendar.py @@ -10,8 +10,9 @@ from shiftings.cal.forms.day_form import SelectDayForm from shiftings.cal.views.calendar_base import CalendarBaseView +from shiftings.cal.views.helpers import build_shift_type_index, get_shifts_for_date from shiftings.shifts.forms.participant import AddSelfParticipantForm -from shiftings.shifts.models import Shift, ShiftType +from shiftings.shifts.models import Shift class DayView(CalendarBaseView, ABC): @@ -60,10 +61,13 @@ class DetailDayView(DayView): } def get_shifts(self, theday: date) -> Any: - shift_filter = (Q(start__date=theday) | Q(end__date=theday, end__gt=theday) | - Q(start__lt=theday, end__gt=theday)) - shift_filter &= self.get_filters() - shifts = Shift.objects.filter(shift_filter).order_by('start', 'end', 'shift_type') + shift_filter = get_shifts_for_date(theday) & self.get_filters() + shifts = ( + Shift.objects.filter(shift_filter) + .select_related('organization', 'event', 'shift_type') + .prefetch_related('participants', 'participants__user') + .order_by('start', 'end', 'shift_type') + ) return [shift for shift in shifts if shift.can_see(self.request.user)] @@ -75,20 +79,11 @@ class ShiftTypesDayView(DayView): def get_shifts(self, theday: date): shift_filter = Q(start__date=theday) & self.get_filters() - shifts = Shift.objects.filter(shift_filter).order_by('start', 'shift_type') + shifts = ( + Shift.objects.filter(shift_filter) + .select_related('organization', 'event', 'shift_type') + .prefetch_related('participants', 'participants__user') + .order_by('start', 'shift_type') + ) shifts = [shift for shift in shifts if shift.can_see_details(self.request.user)] - add_default = False - shift_idx_type = { - 'time_containers': {}, - 'types': list(ShiftType.objects.filter(shift__in=shifts).distinct()) - } - for shift in shifts: - if shift.shift_type is None: - add_default = True - type_name = 'Default' - else: - type_name = shift.shift_type.name - shift_idx_type['time_containers'].setdefault(shift.start.hour, {}).setdefault(type_name, []).append(shift) - if add_default: - shift_idx_type['types'].append(None) - return shift_idx_type + return build_shift_type_index(shifts) diff --git a/src/shiftings/cal/views/helpers.py b/src/shiftings/cal/views/helpers.py new file mode 100644 index 0000000..af2f994 --- /dev/null +++ b/src/shiftings/cal/views/helpers.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +from django.db.models import Q + +from shiftings.shifts.models import Shift, ShiftType + + +def get_shifts_for_date(target_date: date) -> Q: + """Build Q filter for shifts that overlap with the given date. + + Matches shifts that: + - Start on target_date + - End on target_date (and extends past it) + - Span across target_date + """ + return (Q(start__date=target_date) | Q(end__date=target_date, end__gt=target_date) | + Q(start__lt=target_date, end__gt=target_date)) + + +def build_shift_type_index(shifts: list[Shift]) -> dict[str, Any]: + add_default = False + shift_idx_type = { + 'time_containers': {}, + 'types': list(ShiftType.objects.filter(shift__in=shifts).distinct()) + } + for shift in shifts: + if shift.shift_type is None: + add_default = True + type_name = 'Default' + else: + type_name = shift.shift_type.name + shift_idx_type['time_containers'].setdefault(shift.start.hour, {}).setdefault(type_name, []).append(shift) + if add_default: + shift_idx_type['types'].append(None) + return shift_idx_type diff --git a/src/shiftings/cal/views/list.py b/src/shiftings/cal/views/list.py index cce6c08..949e491 100644 --- a/src/shiftings/cal/views/list.py +++ b/src/shiftings/cal/views/list.py @@ -4,15 +4,15 @@ from django.db.models import Q from django.conf import settings -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.urls import reverse from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ from shiftings.cal.forms.day_form import SelectDayForm from shiftings.cal.views.calendar_base import CalendarBaseView +from shiftings.cal.views.helpers import build_shift_type_index from shiftings.shifts.forms.participant import AddSelfParticipantForm -from shiftings.shifts.models import Shift, ShiftType +from shiftings.shifts.models import Shift class ListView(CalendarBaseView, ABC): @@ -70,18 +70,4 @@ def get_shifts(self): shift_filter = self.get_filters() shifts = Shift.objects.filter(shift_filter).order_by('start', 'shift_type')[:settings.MAX_LIST_ENTRIES] shifts = [shift for shift in shifts if shift.can_see_details(self.request.user)] - add_default = False - shift_idx_type = { - 'time_containers': {}, - 'types': list(ShiftType.objects.filter(shift__in=shifts).distinct()) - } - for shift in shifts: - if shift.shift_type is None: - add_default = True - type_name = 'Default' - else: - type_name = shift.shift_type.name - shift_idx_type['time_containers'].setdefault(shift.start.hour, {}).setdefault(type_name, []).append(shift) - if add_default: - shift_idx_type['types'].append(None) - return shift_idx_type + return build_shift_type_index(shifts) diff --git a/src/shiftings/cal/views/month_calendar.py b/src/shiftings/cal/views/month_calendar.py index 407c7c0..6c68cb1 100644 --- a/src/shiftings/cal/views/month_calendar.py +++ b/src/shiftings/cal/views/month_calendar.py @@ -12,6 +12,7 @@ from shiftings.accounts.models import User from shiftings.cal.views.calendar_base import CalendarBaseView +from shiftings.cal.views.helpers import get_shifts_for_date from shiftings.shifts.models import RecurringShift, Shift @@ -64,10 +65,13 @@ def __init__(self, _date: date, user: User, request, shift_filter) -> None: self.shift_filter = shift_filter def get_shifts(self, _date: date) -> Union[List[Shift], QuerySet[Shift]]: - - time_filter = (Q(start__date=_date) | Q(end__date=_date, end__gt=_date) | - Q(start__lt=_date, end__gt=_date)) - return Shift.objects.filter(self.shift_filter & time_filter).order_by('start', 'end') + time_filter = get_shifts_for_date(_date) + return ( + Shift.objects.filter(self.shift_filter & time_filter) + .select_related('organization', 'event', 'shift_type') + .prefetch_related('participants', 'participants__user') + .order_by('start', 'end') + ) def can_see_shift(self, shift: Shift) -> bool: return True diff --git a/src/shiftings/cal/views/week_calendar.py b/src/shiftings/cal/views/week_calendar.py index ee3fe77..3c23ec8 100644 --- a/src/shiftings/cal/views/week_calendar.py +++ b/src/shiftings/cal/views/week_calendar.py @@ -6,6 +6,7 @@ from django.db.models import Q from shiftings.cal.views.calendar_base import CalendarBaseView +from shiftings.cal.views.helpers import get_shifts_for_date from shiftings.events.models import Event from shiftings.organizations.models import Organization from shiftings.shifts.models import Shift @@ -17,10 +18,13 @@ class WeekView(CalendarBaseView): def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) theday = date.fromisoformat(self.kwargs.get('theday')) if 'theday' in self.kwargs else date.today() - shift_filter = (Q(start__date=theday) | Q(end__date=theday) | - Q(start__lt=theday, end__gt=theday)) - shift_filter &= self.get_filters() - shifts = Shift.objects.filter(shift_filter).order_by('shift_type', 'start', 'end') + shift_filter = get_shifts_for_date(theday) & self.get_filters() + shifts = ( + Shift.objects.filter(shift_filter) + .select_related('organization', 'event', 'shift_type') + .prefetch_related('participants', 'participants__user') + .order_by('shift_type', 'start', 'end') + ) context.update({ 'theday': theday, 'nextday': theday + timedelta(days=1), diff --git a/src/shiftings/events/admin.py b/src/shiftings/events/admin.py index a69763b..3c80b23 100644 --- a/src/shiftings/events/admin.py +++ b/src/shiftings/events/admin.py @@ -1,5 +1,27 @@ from django.contrib import admin from shiftings.events.models import Event +from shiftings.utils.admin import BaseModelAdmin, RelatedObjectsFilter +from shiftings.organizations.models import Organization -admin.site.register(Event) + +class OrganizationWithEventsFilter(RelatedObjectsFilter): + title = 'Organization' + parameter_name = 'organization' + related_model = Organization + related_model_filter = {'events__isnull': False} + queryset_filter_field = 'organization' + +@admin.register(Event) +class EventAdmin(BaseModelAdmin): + search_fields = ( + 'name', + 'organization__name', + 'email', + 'website', + 'description', + ) + list_filter = ( + OrganizationWithEventsFilter, + 'start_date', + ) diff --git a/src/shiftings/events/tests/test_event_forms.py b/src/shiftings/events/tests/test_event_forms.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shiftings/events/urls/event.py b/src/shiftings/events/urls/event.py index 960dc2a..2918f45 100644 --- a/src/shiftings/events/urls/event.py +++ b/src/shiftings/events/urls/event.py @@ -3,6 +3,7 @@ from shiftings.cal.feed.event import EventFeed, PublicEventsFeed from shiftings.events.views.event import EventDetailView, EventEditView, EventListView, FutureEventsListView, \ MyEventsListView, MyFutureEventsListView +from shiftings.utils.url_patterns import organization_crud_paths urlpatterns = [ path('', EventListView.as_view(), name='events'), @@ -11,7 +12,8 @@ path('my_upcoming/', MyFutureEventsListView.as_view(), name='my_future_events'), path('calendar/', PublicEventsFeed(), name='public_events_calendar'), path('/', EventDetailView.as_view(), name='event'), - path('create//', EventEditView.as_view(), name='event_create'), - path('/update/', EventEditView.as_view(), name='event_update'), path('/calendar/', EventFeed(), name='event_calendar'), -] +] + organization_crud_paths( + edit_view=EventEditView, + name_prefix='event', +) diff --git a/src/shiftings/events/views/event.py b/src/shiftings/events/views/event.py index 6a8772b..c153a17 100644 --- a/src/shiftings/events/views/event.py +++ b/src/shiftings/events/views/event.py @@ -12,7 +12,7 @@ from shiftings.events.forms.event import EventForm from shiftings.events.models import Event from shiftings.organizations.models import Organization -from shiftings.organizations.views.organization_base import OrganizationPermissionMixin +from shiftings.organizations.views.organization_base import OrganizationCreateUpdateMixin, OrganizationPermissionMixin from shiftings.utils.typing import UserRequest from shiftings.utils.views.base import BaseMixin, BasePermissionMixin from shiftings.utils.views.create_update_view import CreateOrUpdateViewWithImageUpload @@ -83,24 +83,15 @@ class EventDetailView(EventMixin, DetailView): context_object_name = 'event' -class EventEditView(BasePermissionMixin, CreateOrUpdateViewWithImageUpload): +class EventEditView(OrganizationCreateUpdateMixin, BasePermissionMixin, CreateOrUpdateViewWithImageUpload): model = Event form_class = EventForm permission_required = 'organizations.edit_events' - - def get_initial(self) -> dict[str, Any]: - initial = super().get_initial() - initial['organization'] = self.get_organization() - return initial + set_organization_initial_on_create_only = False def get_event(self) -> Event: return self._get_object(Event, 'pk') - def get_organization(self) -> Organization: - if self.is_create(): - return self._get_object(Organization, 'org_pk') - return self.get_event().organization - def get_obj(self) -> Optional[Event]: if self.is_create(): return None diff --git a/src/shiftings/formats/__init__.py b/src/shiftings/formats/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shiftings/formats/de/__init__.py b/src/shiftings/formats/de/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shiftings/formats/de/formats.py b/src/shiftings/formats/de/formats.py new file mode 100644 index 0000000..19448ca --- /dev/null +++ b/src/shiftings/formats/de/formats.py @@ -0,0 +1,6 @@ +DATE_FORMAT = 'd.m.Y' +TIME_FORMAT = 'H:i' +DATETIME_FORMAT = 'd.m.Y H:i' + +SHORT_DATE_FORMAT = 'd.m.Y' +SHORT_DATETIME_FORMAT = 'd.m.Y H:i' diff --git a/src/shiftings/formats/en/__init__.py b/src/shiftings/formats/en/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shiftings/formats/en/formats.py b/src/shiftings/formats/en/formats.py new file mode 100644 index 0000000..19448ca --- /dev/null +++ b/src/shiftings/formats/en/formats.py @@ -0,0 +1,6 @@ +DATE_FORMAT = 'd.m.Y' +TIME_FORMAT = 'H:i' +DATETIME_FORMAT = 'd.m.Y H:i' + +SHORT_DATE_FORMAT = 'd.m.Y' +SHORT_DATETIME_FORMAT = 'd.m.Y H:i' diff --git a/src/shiftings/local_settings.sample.py b/src/shiftings/local_settings.sample.py index 1ab7420..1f24621 100644 --- a/src/shiftings/local_settings.sample.py +++ b/src/shiftings/local_settings.sample.py @@ -63,6 +63,13 @@ # Maximum number of entries for detailed list views MAX_LIST_ENTRIES = 256 +# Token security validation tuning +TOKEN_SECURITY_MAX_FAILED_ATTEMPTS_PER_IP = 5 +TOKEN_SECURITY_FAILED_ATTEMPT_WINDOW_HOURS = 1 +TOKEN_SECURITY_MAX_FAILED_ATTEMPTS_PER_USER = 10 +TOKEN_SECURITY_FAILED_ATTEMPT_WINDOW_USER_HOURS = 24 +TOKEN_SECURITY_MAX_TOKEN_LENGTH = 255 + MEDIA_URL = '/media/' # Secure media with login. set the current values are 'apache2' and 'nginx' SERVE_MEDIA_SERVER = 'apache2' diff --git a/src/shiftings/mail/admin.py b/src/shiftings/mail/admin.py index 8c38f3f..93a0235 100644 --- a/src/shiftings/mail/admin.py +++ b/src/shiftings/mail/admin.py @@ -1,3 +1,35 @@ -from django.contrib import admin +from django.contrib import admin, messages +from django.contrib.auth import get_user_model -# Register your models here. +from .models import OutgoingEmail +from shiftings.utils.admin import BaseModelAdmin, RelatedObjectsFilter + + +class SentByUserFilter(RelatedObjectsFilter): + title = 'sender' + parameter_name = 'sender' + related_model = get_user_model() + related_model_filter = {'sent_emails__isnull': False} + related_label_field = 'username' + queryset_filter_field = 'sender' + + +@admin.register(OutgoingEmail) +class OutgoingEmailAdmin(BaseModelAdmin): + list_display = ('subject', 'sender', 'from_email', 'recipient_count', 'sent_at') + search_fields = ('subject', 'body', 'recipients', 'from_email', 'bcc', 'reply_to') + list_filter = ('sent_at', SentByUserFilter) + ordering = ('-sent_at',) + readonly_fields = ('subject', 'body', 'from_email', 'recipients', 'bcc', 'reply_to', 'sent_at', 'sender') + + def has_add_permission(self, request): + """Disallow creating new OutgoingEmail objects via the admin UI.""" + return False + + def recipient_count(self, obj: OutgoingEmail) -> int: # pragma: no cover - admin helper + if not obj.recipients: + return 0 + # recipients stored as newline or comma separated + parts = [p.strip() for p in obj.recipients.replace('\n', ',').split(',') if p.strip()] + return len(parts) + recipient_count.short_description = 'Recipient count' diff --git a/src/shiftings/mail/forms/mail.py b/src/shiftings/mail/forms/mail.py index 931a35d..e4798d7 100644 --- a/src/shiftings/mail/forms/mail.py +++ b/src/shiftings/mail/forms/mail.py @@ -17,18 +17,17 @@ class MailForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['attachments'].widget.attrs['multiple'] = True - + def clean(self) -> dict[str, Any]: cleaned_data = super().clean() attachments = self.files.getlist('attachments') for attachment in attachments: if attachment.size > MAX_ATTACHMENT_SIZE_MB * 1024 * 1024: # Convert MB to bytes - self.add_error('attachments', _('Each attachment must be smaller than 10 MB.')) - break - + self.add_error('attachments', _('All attachments must be smaller than 10 MB. "%(filename)s" has size %(size)d MB. \n Either upload a smaller file or remove it.') % {'filename': attachment.name, 'size': attachment.size / (1024 * 1024)}) + if sum(attachment.size for attachment in attachments) > MAX_TOTAL_ATTACHMENT_SIZE_MB * 1024 * 1024: - self.add_error('attachments', _('Total attachment size must be smaller than 25 MB.')) - + self.add_error('attachments', _('Total attachment size must be smaller than 25 MB. Your total attachment size is %(size)d MB. \n Either upload smaller files or remove some attachments.') % {'size': sum(attachment.size for attachment in attachments) / (1024 * 1024)}) + return cleaned_data @@ -68,5 +67,5 @@ def clean(self) -> Optional[dict[str, Any]]: start = cleaned_data.get('start') end = cleaned_data.get('end') if start and end and start > end: - raise forms.ValidationError(_('Start time must be before end time.')) - return cleaned_data \ No newline at end of file + raise forms.ValidationError(_('Start time must be before end time. %(start)s is not before %(end)s. You can swap the times to fix this.') % {'start': start, 'end': end}) + return cleaned_data diff --git a/src/shiftings/mail/migrations/0001_initial.py b/src/shiftings/mail/migrations/0001_initial.py new file mode 100644 index 0000000..64db043 --- /dev/null +++ b/src/shiftings/mail/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.2 on 2026-05-19 17:43 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OutgoingEmail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject', models.CharField(max_length=255)), + ('body', models.TextField()), + ('from_email', models.EmailField(max_length=254)), + ('recipients', models.TextField(help_text='Comma- or newline-separated recipient addresses')), + ('bcc', models.TextField(blank=True, null=True)), + ('reply_to', models.EmailField(blank=True, max_length=254, null=True)), + ('sent_at', models.DateTimeField(auto_now_add=True)), + ('attachments', models.TextField(blank=True, help_text='JSON or comma-separated attachment metadata', null=True)), + ('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_emails', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Outgoing email', + 'verbose_name_plural': 'Outgoing emails', + 'ordering': ('-sent_at',), + }, + ), + ] diff --git a/src/shiftings/mail/models.py b/src/shiftings/mail/models.py new file mode 100644 index 0000000..6872f97 --- /dev/null +++ b/src/shiftings/mail/models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from django.conf import settings +from django.db import models + + +class OutgoingEmail(models.Model): + """A simple log model for outgoing emails sent via the application. + + Stores basic metadata and full body so admins can inspect and export messages. + """ + + subject = models.CharField(max_length=255) + body = models.TextField() + from_email = models.EmailField() + recipients = models.TextField(help_text='Comma- or newline-separated recipient addresses') + bcc = models.TextField(blank=True, null=True) + reply_to = models.EmailField(blank=True, null=True) + sender = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, + related_name='sent_emails') + sent_at = models.DateTimeField(auto_now_add=True) + attachments = models.TextField(blank=True, null=True, help_text='JSON or comma-separated attachment metadata') + + class Meta: + ordering = ('-sent_at',) + verbose_name = 'Outgoing email' + verbose_name_plural = 'Outgoing emails' + + def __str__(self) -> str: # pragma: no cover - trivial + when = self.sent_at.strftime('%Y-%m-%d %H:%M') if self.sent_at else 'unsent' + return f"{self.subject} — {when}" + + def recipients_list(self) -> list[str]: + if not self.recipients: + return [] + parts = [p.strip() for p in self.recipients.replace('\n', ',').split(',') if p.strip()] + return parts diff --git a/src/shiftings/mail/tests/test_mail_forms.py b/src/shiftings/mail/tests/test_mail_forms.py new file mode 100644 index 0000000..ea46a34 --- /dev/null +++ b/src/shiftings/mail/tests/test_mail_forms.py @@ -0,0 +1,66 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.utils.datastructures import MultiValueDict + +from shiftings.mail.forms.mail import MailForm, OrganizationMailForm, ShiftParticipantMailForm +from shiftings.mail.settings import MAX_ATTACHMENT_SIZE_MB, MAX_TOTAL_ATTACHMENT_SIZE_MB +from shiftings.organizations.models import Organization + + +class MailFormTest(TestCase): + fixtures = ['user', 'organization', 'shift'] + + def _mb_file(self, name: str, size_mb: int) -> SimpleUploadedFile: + return SimpleUploadedFile(name, b'x' * (size_mb * 1024 * 1024)) + + def test_single_attachment_size_limit(self) -> None: + form = MailForm( + data={'subject': 'Subject', 'text': 'Body'}, + files=MultiValueDict({'attachments': [self._mb_file('too-large.txt', MAX_ATTACHMENT_SIZE_MB + 1)]}), + ) + + self.assertFalse(form.is_valid()) + self.assertIn('attachments', form.errors) + + def test_total_attachment_size_limit(self) -> None: + half = MAX_TOTAL_ATTACHMENT_SIZE_MB // 2 + form = MailForm( + data={'subject': 'Subject', 'text': 'Body'}, + files=MultiValueDict({'attachments': [ + self._mb_file('a.txt', half + 1), + self._mb_file('b.txt', half + 1), + ]}), + ) + + self.assertFalse(form.is_valid()) + self.assertIn('attachments', form.errors) + + def test_organization_mail_form_uses_organization_membership_types(self) -> None: + organization = Organization.objects.get(pk=1) + + form = OrganizationMailForm(organization=organization) + + self.assertEqual( + set(form.fields['membership_types'].queryset.values_list('pk', flat=True)), + set(organization.membership_types.values_list('pk', flat=True)), + ) + + +class ShiftParticipantMailFormTest(TestCase): + fixtures = ['user', 'organization', 'shift'] + + def test_rejects_start_after_end(self) -> None: + organization = Organization.objects.get(pk=1) + form = ShiftParticipantMailForm( + organization=organization, + data={ + 'subject': 'Subject', + 'text': 'Body', + 'start': '2024-01-02 12:00:00', + 'end': '2024-01-01 12:00:00', + 'shift_types': [], + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('__all__', form.errors) diff --git a/src/shiftings/mail/views/mail.py b/src/shiftings/mail/views/mail.py index df7d360..d9d87ed 100644 --- a/src/shiftings/mail/views/mail.py +++ b/src/shiftings/mail/views/mail.py @@ -13,6 +13,7 @@ from shiftings.mail.forms.mail import MailForm from shiftings.utils.typing import UserRequest from shiftings.utils.views.base import BaseLoginMixin +from shiftings.mail.models import OutgoingEmail class BaseMailView(BaseLoginMixin, FormView, ABC): @@ -37,11 +38,31 @@ def form_valid(self, form: MailForm) -> HttpResponse: subject = form.cleaned_data['subject'].format(**replacements) text = form.cleaned_data['text'].format(**replacements) users = self.get_users(form) - email = EmailMessage(subject, text, settings.DEFAULT_FROM_EMAIL, bcc=[user.email for user in users], - headers={'Reply-To': settings.DEFAULT_FROM_EMAIL}) + recipient_emails = [user.email for user in users] + email = EmailMessage(subject, text, settings.DEFAULT_FROM_EMAIL, + bcc=recipient_emails, headers={'Reply-To': settings.DEFAULT_FROM_EMAIL}) + attachment_names: list[str] = [] for file in dict(form.files).get('attachments', list()): + attachment_names.append(file.name) email.attach(file.name, file.file.read(), mimetype=file.content_type) email.send() + + # Log outgoing email for admin inspection + try: + OutgoingEmail.objects.create( + subject=subject, + body=text, + from_email=settings.DEFAULT_FROM_EMAIL, + recipients='\n'.join(recipient_emails), + bcc='\n'.join(dict(form.files).get('bcc', [])) if hasattr(form, 'cleaned_data') else '', + reply_to=settings.DEFAULT_FROM_EMAIL, + sender=getattr(self.request, 'user', None), + attachments=','.join(attachment_names) + ) + except Exception: + # Do not block sending on logging errors; admins can investigate failures in logs + pass + messages.success(self.request, _('E-Mail sent to {count} user(s).').format(count=users.count())) return HttpResponseRedirect(self.get_success_url()) diff --git a/src/shiftings/organizations/admin.py b/src/shiftings/organizations/admin.py index c571d5c..2bd5b01 100644 --- a/src/shiftings/organizations/admin.py +++ b/src/shiftings/organizations/admin.py @@ -1,8 +1,45 @@ from django.contrib import admin +from django.contrib.auth.models import Group +from django.contrib.auth.admin import GroupAdmin as DefaultGroupAdmin from shiftings.organizations.models import Membership, MembershipType, Organization, OrganizationDummyUser +from shiftings.utils.admin import RelatedObjectsFilter, register_models -admin.site.register(Organization) -admin.site.register(Membership) -admin.site.register(MembershipType) -admin.site.register(OrganizationDummyUser) + +class OrganizationGroupFilter(RelatedObjectsFilter): + title = 'Organization' + parameter_name = 'organization' + related_model = Organization + related_model_filter = {'members__group__isnull': False} + queryset_filter_field = 'memberships__organization' + include_empty_choice = True + empty_choice_label = 'No organization' + empty_queryset_filter = {'memberships__isnull': True} + + +try: + admin.site.unregister(Group) +except admin.sites.NotRegistered: + pass + + +class GroupAdmin(DefaultGroupAdmin): + def get_list_filter(self, request): + base = super().get_list_filter(request) + try: + # ensure it's a tuple before appending + return tuple(list(base) + [OrganizationGroupFilter]) + except Exception: + return (OrganizationGroupFilter,) + + +admin.site.register(Group, GroupAdmin) + + +register_models(Organization,) + +register_models( + Membership, + MembershipType, + OrganizationDummyUser, +) diff --git a/src/shiftings/organizations/forms/membership.py b/src/shiftings/organizations/forms/membership.py index 208c118..c611a6c 100644 --- a/src/shiftings/organizations/forms/membership.py +++ b/src/shiftings/organizations/forms/membership.py @@ -37,10 +37,8 @@ def clean_user(self) -> Optional[User]: try: user = User.objects.get(username=username) except User.DoesNotExist as e: - raise ValidationError(_('The user you entered could not be found.')) from e + raise ValidationError(_('The user you entered could not be found. Please try another username or contact your organization administrator.')) from e return user - - class MembershipTypeForm(forms.ModelForm): permissions = MembershipPermissionField(queryset=Permission.objects.none(), widget=forms.CheckboxSelectMultiple, required=False) diff --git a/src/shiftings/organizations/management/__init__.py b/src/shiftings/organizations/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shiftings/organizations/management/commands/__init__.py b/src/shiftings/organizations/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/shiftings/organizations/management/commands/prune_org_activity_logs.py b/src/shiftings/organizations/management/commands/prune_org_activity_logs.py new file mode 100644 index 0000000..ea86fd3 --- /dev/null +++ b/src/shiftings/organizations/management/commands/prune_org_activity_logs.py @@ -0,0 +1,15 @@ +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from shiftings.organizations.models import OrganizationActivityLog + + +class Command(BaseCommand): + help = 'Delete organization activity log entries older than 90 days.' + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(days=90) + deleted_count, _ = OrganizationActivityLog.objects.filter(created_at__lt=cutoff).delete() + self.stdout.write(f'Deleted {deleted_count} organization activity log entries older than {cutoff}.') diff --git a/src/shiftings/organizations/models/__init__.py b/src/shiftings/organizations/models/__init__.py index c849c63..2df681b 100644 --- a/src/shiftings/organizations/models/__init__.py +++ b/src/shiftings/organizations/models/__init__.py @@ -1,3 +1,4 @@ +from .activity_log import OrganizationActivityLog from .membership import Membership, MembershipType from .organization import Organization from .user import OrganizationDummyUser diff --git a/src/shiftings/organizations/models/activity_log.py b/src/shiftings/organizations/models/activity_log.py new file mode 100644 index 0000000..8e483de --- /dev/null +++ b/src/shiftings/organizations/models/activity_log.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +if TYPE_CHECKING: + from shiftings.accounts.models import User + from shiftings.organizations.models.organization import Organization + + +class OrganizationActivityLog(models.Model): + class Action(models.TextChoices): + MEMBERSHIP_ADDED = 'membership_added', _('Membership added') + MEMBERSHIP_REMOVED = 'membership_removed', _('Membership removed') + MEMBERSHIP_TYPE_CREATED = 'membership_type_created', _('Membership type created') + MEMBERSHIP_TYPE_UPDATED = 'membership_type_updated', _('Membership type updated') + MEMBERSHIP_TYPE_REMOVED = 'membership_type_removed', _('Membership type removed') + ORGANIZATION_UPDATED = 'organization_updated', _('Organization updated') + SHIFT_CREATED = 'shift_created', _('Shift created') + SHIFT_UPDATED = 'shift_updated', _('Shift updated') + SHIFT_REMOVED = 'shift_removed', _('Shift removed') + SHIFT_PARTICIPANT_ADDED_SELF = 'shift_participant_added_self', _('Shift participant added (self)') + SHIFT_PARTICIPANT_ADDED_OTHER = 'shift_participant_added_other', _('Shift participant added (other)') + SHIFT_PARTICIPANT_REMOVED = 'shift_participant_removed', _('Shift participant removed') + RECURRING_SHIFT_CREATED = 'recurring_shift_created', _('Recurring shift created') + RECURRING_SHIFT_UPDATED = 'recurring_shift_updated', _('Recurring shift updated') + RECURRING_SHIFT_REMOVED = 'recurring_shift_removed', _('Recurring shift removed') + RECURRING_SHIFT_CREATE_SHIFTS = 'recurring_shift_create_shifts', _('Recurring shifts generated') + + organization = models.ForeignKey( + 'Organization', + on_delete=models.CASCADE, + related_name='activity_logs', + verbose_name=_('Organization'), + ) + actor = models.ForeignKey( + 'accounts.User', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='organization_activity_logs', + verbose_name=_('Actor'), + ) + action = models.CharField(max_length=64, choices=Action.choices, verbose_name=_('Action')) + summary = models.CharField(max_length=255, verbose_name=_('Summary')) + metadata = models.JSONField(default=dict, blank=True, verbose_name=_('Metadata')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) + + class Meta: + default_permissions = () + ordering = ['-created_at', '-pk'] + indexes = [ + models.Index(fields=['organization', '-created_at']), + models.Index(fields=['organization', 'action', '-created_at']), + ] + verbose_name = _('Organization activity log entry') + verbose_name_plural = _('Organization activity log entries') + + def __str__(self) -> str: + return f'[{self.get_action_display()}] {self.summary}' diff --git a/src/shiftings/organizations/models/membership.py b/src/shiftings/organizations/models/membership.py index f843813..b8e5375 100644 --- a/src/shiftings/organizations/models/membership.py +++ b/src/shiftings/organizations/models/membership.py @@ -97,6 +97,6 @@ def is_member(self, user): def clean(self) -> None: if self.user and self.group: - raise ValidationError(_('Membership can only be either user or group, not both.')) + raise ValidationError(_('Membership can only be inherited from either the user or a group, not both.')) if not self.user and not self.group: - raise ValidationError(_('Membership must consist of a user or a group.')) + raise ValidationError(_('Membership must be inherited from a user or a group. It cannot be empty.')) diff --git a/src/shiftings/organizations/models/organization.py b/src/shiftings/organizations/models/organization.py index eb2634c..317aa9e 100644 --- a/src/shiftings/organizations/models/organization.py +++ b/src/shiftings/organizations/models/organization.py @@ -27,7 +27,7 @@ class Organization(models.Model): email = models.EmailField(verbose_name=_('E-Mail'), blank=True, null=True) telephone_number = PhoneNumberField(verbose_name=_('Telephone Number'), blank=True, null=True) website = models.URLField(verbose_name=_('Website'), blank=True, null=True, - help_text=_('Include Protocol i.E. https://example.com')) + help_text=_('Include protocol, e.g.') + ' https://example.com') description = models.TextField(max_length=1000, verbose_name=_('Description'), blank=True, null=True, help_text=_('A maximum of {amount} characters is allowed').format(amount=1000)) diff --git a/src/shiftings/organizations/services/__init__.py b/src/shiftings/organizations/services/__init__.py new file mode 100644 index 0000000..7b1af9d --- /dev/null +++ b/src/shiftings/organizations/services/__init__.py @@ -0,0 +1,3 @@ +from .activity_log import build_changed_fields, log_organization_activity + +__all__ = ['build_changed_fields', 'log_organization_activity'] diff --git a/src/shiftings/organizations/services/activity_log.py b/src/shiftings/organizations/services/activity_log.py new file mode 100644 index 0000000..ee327db --- /dev/null +++ b/src/shiftings/organizations/services/activity_log.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +from shiftings.accounts.models import User +from shiftings.organizations.models import Organization, OrganizationActivityLog + + +def build_changed_fields(old_values: dict[str, Any], updated_object: Any) -> dict[str, dict[str, str]]: + changed_fields: dict[str, dict[str, str]] = {} + for field_name, old_value in old_values.items(): + new_value = getattr(updated_object, field_name) + if old_value == new_value: + continue + changed_fields[field_name] = { + 'old': '' if old_value is None else str(old_value), + 'new': '' if new_value is None else str(new_value), + } + return changed_fields + + +def log_organization_activity(*, + organization: Organization, + action: OrganizationActivityLog.Action, + summary: str, + actor: User | None = None, + metadata: dict[str, Any] | None = None) -> None: + OrganizationActivityLog.objects.create( + organization=organization, + actor=actor, + action=action, + summary=summary, + metadata=metadata or {}, + ) diff --git a/src/shiftings/organizations/templates/organizations/organization_activity_log.html b/src/shiftings/organizations/templates/organizations/organization_activity_log.html new file mode 100644 index 0000000..3d1f0d0 --- /dev/null +++ b/src/shiftings/organizations/templates/organizations/organization_activity_log.html @@ -0,0 +1,59 @@ +{% extends 'generic/dual_column.html' %} + +{% block left %} +
+ {% include "organizations/template/org_info.html" %} +
+{% endblock %} + +{% block right %} +
+
+

{% trans "Activity Log" %}

+ {% include 'generic/modular_search.html' with placeholder=_('Search Actor, Action or Summary') %} +
+
+ {% if activity_logs.object_list|length > 0 %} +

{% trans "Organization" %} -

{% trans "No Shifts found with these parameters or planned on this day" %}

+
+

{% trans "No Shifts found with these parameters or planned on this day" %}

+ + + + + + + + + + + {% for log_entry in activity_logs.object_list %} + + + + + + + + {% endfor %} + +
{% trans "Date" %}{% trans "Actor" %}{% trans "Action" %}{% trans "Summary" %}{% trans "Target" %}
{{ log_entry.created_at }}{{ log_entry.actor|default:_('System') }}{{ log_entry.get_action_display }}{{ log_entry.summary }} + {% if log_entry.metadata.target_url %} + + {{ log_entry.metadata.target_label|default:_('Open') }} + + {% elif log_entry.metadata.target_label %} + {{ log_entry.metadata.target_label }} + {% else %} + - + {% endif %} +
+ {% else %} +
{% trans "No activity log entries found" %}
+ {% endif %} +
+ {% if activity_logs.is_paginated %} +
+ {% bootstrap_pagination activity_logs.page_obj parameter_name=activity_logs.page_name %} +
+ {% endif %} +
+{% endblock %} diff --git a/src/shiftings/organizations/templates/organizations/organization_admin.html b/src/shiftings/organizations/templates/organizations/organization_admin.html index 659dbe3..c17f8ad 100644 --- a/src/shiftings/organizations/templates/organizations/organization_admin.html +++ b/src/shiftings/organizations/templates/organizations/organization_admin.html @@ -295,11 +295,8 @@
- - - + {% url 'shift_type_delete' shift_type.pk as shift_type_delete_url %} + {% include "template/remove_modal_trigger.html" with tag="a" button_class="btn btn-sm btn-danger ms-1" href="#confirmRemoveModal" remove_name=shift_type.name remove_url=shift_type_delete_url icon_class="fas fa-trash" %} {% endfor %} @@ -419,4 +416,4 @@
{% trans "Shifts" %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/organizations/templates/organizations/organization_settings.html b/src/shiftings/organizations/templates/organizations/organization_settings.html index 54f7b68..6d616f0 100644 --- a/src/shiftings/organizations/templates/organizations/organization_settings.html +++ b/src/shiftings/organizations/templates/organizations/organization_settings.html @@ -61,11 +61,8 @@

{% trans "Shift Type Groups" %}

title="{% trans "Edit Type Group" %}"> - - - + {% url 'shift_type_group_remove' type_group.pk as shift_type_group_remove_url %} + {% include "template/remove_modal_trigger.html" with tag="a" button_class="btn btn-danger ms-1" href="#confirmRemoveModal" remove_name=type_group.name remove_url=shift_type_group_remove_url icon_class="fas fa-trash" %} {% if not forloop.first %}
{% csrf_token %} @@ -97,4 +94,4 @@

{% trans "Shift Type Groups" %}

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/organizations/templates/organizations/organization_shifts.html b/src/shiftings/organizations/templates/organizations/organization_shifts.html index d2767ba..307c12b 100644 --- a/src/shiftings/organizations/templates/organizations/organization_shifts.html +++ b/src/shiftings/organizations/templates/organizations/organization_shifts.html @@ -13,7 +13,7 @@

{% trans "Member Shift Summary" %} ({{ organization.summary_settings.default
- {% member_shift_summary organization False False %} + {% shift_users_summary organization False False %}
{% endif %} diff --git a/src/shiftings/organizations/templates/organizations/template/member.html b/src/shiftings/organizations/templates/organizations/template/member.html index 2465291..89b6e00 100644 --- a/src/shiftings/organizations/templates/organizations/template/member.html +++ b/src/shiftings/organizations/templates/organizations/template/member.html @@ -5,12 +5,9 @@ {% if org_perms.edit_members %} {% if org_perms.admin or not membership_type.admin %} - + {% url 'membership_remove' organization.pk member.pk as membership_remove_url %} + {% include "template/remove_modal_trigger.html" with tag="button" button_class="btn btn-danger px-2 py-0 ms-2 me-4" remove_name=member remove_url=membership_remove_url icon_class="fas fa-x pe-none" %} {% endif %} {% endif %} - \ No newline at end of file + diff --git a/src/shiftings/organizations/templates/organizations/template/member_group.html b/src/shiftings/organizations/templates/organizations/template/member_group.html index fdfaac2..37d3c0c 100644 --- a/src/shiftings/organizations/templates/organizations/template/member_group.html +++ b/src/shiftings/organizations/templates/organizations/template/member_group.html @@ -1,4 +1,4 @@ -
+
{% if org_perms.edit_members %} {% if org_perms.admin or not membership_type.admin %} - + {% url 'membership_remove' organization.pk member.pk as membership_remove_url %} + {% include "template/remove_modal_trigger.html" with tag="button" button_class="btn btn-danger py-0 px-2 me-2" remove_name=member_group remove_url=membership_remove_url icon_class="fas fa-x pe-none" %} {% endif %} {% endif %}
@@ -35,4 +32,4 @@
- \ No newline at end of file + diff --git a/src/shiftings/organizations/templates/organizations/template/org_info.html b/src/shiftings/organizations/templates/organizations/template/org_info.html index bbf20be..349f67b 100644 --- a/src/shiftings/organizations/templates/organizations/template/org_info.html +++ b/src/shiftings/organizations/templates/organizations/template/org_info.html @@ -14,6 +14,12 @@ {% trans "Organization Admin View" %} {% if org_perms.admin %} + {% url 'organization_activity_log' organization.pk as activity_url %} + + + {% trans "Activity Log" %} + {% url 'organization_settings' organization.pk as settings_url %} @@ -73,4 +79,4 @@

- \ No newline at end of file + diff --git a/src/shiftings/organizations/tests/test_activity_log_command.py b/src/shiftings/organizations/tests/test_activity_log_command.py new file mode 100644 index 0000000..44f4deb --- /dev/null +++ b/src/shiftings/organizations/tests/test_activity_log_command.py @@ -0,0 +1,39 @@ +from datetime import timedelta + +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone + +from shiftings.accounts.models import User +from shiftings.organizations.models import Organization, OrganizationActivityLog + + +class PruneOrgActivityLogsCommandTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.actor = User.objects.get(username='bob') + + def test_prune_removes_only_entries_older_than_90_days(self) -> None: + old_entry = OrganizationActivityLog.objects.create( + organization=self.organization, + actor=self.actor, + action=OrganizationActivityLog.Action.ORGANIZATION_UPDATED, + summary='Old entry', + ) + recent_entry = OrganizationActivityLog.objects.create( + organization=self.organization, + actor=self.actor, + action=OrganizationActivityLog.Action.ORGANIZATION_UPDATED, + summary='Recent entry', + ) + + OrganizationActivityLog.objects.filter(pk=old_entry.pk).update( + created_at=timezone.now() - timedelta(days=91) + ) + + call_command('prune_org_activity_logs') + + self.assertFalse(OrganizationActivityLog.objects.filter(pk=old_entry.pk).exists()) + self.assertTrue(OrganizationActivityLog.objects.filter(pk=recent_entry.pk).exists()) diff --git a/src/shiftings/organizations/tests/test_membership_forms.py b/src/shiftings/organizations/tests/test_membership_forms.py new file mode 100644 index 0000000..34e935c --- /dev/null +++ b/src/shiftings/organizations/tests/test_membership_forms.py @@ -0,0 +1,69 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from shiftings.accounts.models import User +from shiftings.organizations.forms.membership import MembershipForm +from shiftings.organizations.models import Membership, MembershipType, Organization + + +class MembershipFormTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.admin_user = User.objects.get(username='bob') + self.member_user = User.objects.get(username='perry') + self.target_user = User.objects.get(username='carla') + self.non_admin_user = User.objects.create_user(username='membership-form-user', password='secret') + + def test_non_admin_cannot_select_admin_membership_type(self) -> None: + form = MembershipForm( + user=self.non_admin_user, + initial={'organization': self.organization}, + ) + + type_pks = set(form.fields['type'].queryset.values_list('pk', flat=True)) + self.assertNotIn(MembershipType.objects.get(organization=self.organization, admin=True).pk, type_pks) + + def test_clean_user_returns_user_object_for_existing_username(self) -> None: + form = MembershipForm( + user=self.admin_user, + data={ + 'organization': self.organization.pk, + 'type': MembershipType.objects.get(organization=self.organization, default=True).pk, + 'user': self.target_user.username, + 'group': '', + }, + initial={'organization': self.organization}, + ) + + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['user'], self.target_user) + + def test_clean_user_rejects_unknown_username(self) -> None: + form = MembershipForm( + user=self.admin_user, + data={ + 'organization': self.organization.pk, + 'type': MembershipType.objects.get(organization=self.organization, default=True).pk, + 'user': 'missing-user', + 'group': '', + }, + initial={'organization': self.organization}, + ) + + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors['user'], ['The user you entered could not be found.']) + + +class MembershipModelConstraintTest(TestCase): + fixtures = ['user', 'organization'] + + def test_membership_requires_user_or_group(self) -> None: + organization = Organization.objects.get(pk=1) + membership_type = MembershipType.objects.get(organization=organization, default=True) + + membership = Membership(organization=organization, type=membership_type) + + with self.assertRaises(ValidationError): + membership.full_clean() \ No newline at end of file diff --git a/src/shiftings/organizations/tests/test_membership_views.py b/src/shiftings/organizations/tests/test_membership_views.py new file mode 100644 index 0000000..49def75 --- /dev/null +++ b/src/shiftings/organizations/tests/test_membership_views.py @@ -0,0 +1,126 @@ +from django.contrib.messages import get_messages +from django.test import RequestFactory, TestCase +from django.urls import reverse + +from shiftings.accounts.models import User +from shiftings.organizations.models import Membership, MembershipType, Organization, OrganizationActivityLog +from shiftings.organizations.views.membership import MembershipAddView +from shiftings.organizations.views.membership_type import MembershipTypeEditView, MembershipTypeRemoveView + + +class MembershipViewTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.factory = RequestFactory() + self.organization = Organization.objects.get(pk=1) + self.user = User.objects.get(username='elliot') + + def _setup_view(self, view, **kwargs): + request = self.factory.get('/organizations/test/') + request.user = self.user + view.setup(request, **kwargs) + view.request = request + view.kwargs = kwargs + return view + + def test_membership_add_view_resolves_organization_and_redirect(self) -> None: + view = self._setup_view(MembershipAddView(), org_pk=self.organization.pk) + + self.assertEqual(view.get_organization(), self.organization) + self.assertEqual( + view.get_success_url(), + reverse('organization_admin', args=[self.organization.pk]), + ) + + def test_membership_type_edit_redirects_to_organization_admin(self) -> None: + view = self._setup_view(MembershipTypeEditView(), org_pk=self.organization.pk) + + self.assertEqual( + view.get_success_url(), + reverse('organization_admin', args=[self.organization.pk]), + ) + + def test_membership_type_remove_redirects_to_organization_overview(self) -> None: + membership_type = MembershipType.objects.filter(organization=self.organization).first() + self.assertIsNotNone(membership_type) + + view = self._setup_view( + MembershipTypeRemoveView(), + org_pk=self.organization.pk, + member_pk=membership_type.pk, + ) + + self.assertEqual(view.get_success_url(), self.organization.get_absolute_url()) + + +class MembershipEndpointIntegrationTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.admin_user = User.objects.get(username='bob') + self.target_user = User.objects.get(username='perry') + self.client.force_login(self.admin_user) + + def test_add_member_endpoint_creates_membership(self) -> None: + Membership.objects.filter(organization=self.organization, user=self.target_user).delete() + default_membership_type = self.organization.default_membership_type + + response = self.client.post( + reverse('membership_add_member', kwargs={'org_pk': self.organization.pk}), + { + 'organization': self.organization.pk, + 'type': default_membership_type.pk, + 'user': self.target_user.username, + 'group': '', + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertRedirects( + response, + reverse('organization_admin', args=[self.organization.pk]), + fetch_redirect_response=False, + ) + self.assertTrue( + Membership.objects.filter( + organization=self.organization, + type=default_membership_type, + user=self.target_user, + ).exists() + ) + self.assertTrue( + OrganizationActivityLog.objects.filter( + organization=self.organization, + action=OrganizationActivityLog.Action.MEMBERSHIP_ADDED, + ).exists() + ) + + def test_remove_member_endpoint_deletes_membership_and_sets_success_message(self) -> None: + default_membership_type = self.organization.default_membership_type + membership = Membership.objects.create( + organization=self.organization, + type=default_membership_type, + user=self.target_user, + ) + + response = self.client.post( + reverse( + 'membership_remove', + kwargs={'org_pk': self.organization.pk, 'member_pk': membership.pk}, + ), + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(Membership.objects.filter(pk=membership.pk).exists()) + self.assertEqual(response.redirect_chain[0][0], reverse('organization_admin', args=[self.organization.pk])) + messages = list(get_messages(response.wsgi_request)) + self.assertTrue(any(str(message) == 'Membership removed' for message in messages)) + self.assertTrue( + OrganizationActivityLog.objects.filter( + organization=self.organization, + action=OrganizationActivityLog.Action.MEMBERSHIP_REMOVED, + ).exists() + ) diff --git a/src/shiftings/organizations/tests/test_organization.py b/src/shiftings/organizations/tests/test_organization.py index a16584e..97f327f 100644 --- a/src/shiftings/organizations/tests/test_organization.py +++ b/src/shiftings/organizations/tests/test_organization.py @@ -1,8 +1,13 @@ +from datetime import datetime +import re + from django.contrib.auth.models import Group from django.test import TestCase +from django.urls import reverse +from django.utils import translation from shiftings.accounts.models import User -from shiftings.organizations.models import Membership, MembershipType, Organization +from shiftings.organizations.models import Membership, MembershipType, Organization, OrganizationActivityLog class OrganizationTest(TestCase): @@ -53,3 +58,59 @@ def is_member(username: str) -> bool: self.assertTrue(is_member('janitor')) # group self.assertTrue(is_member('gooch')) + + +class OrganizationActivityLogAccessTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.org_admin = User.objects.get(username='elliot') + self.member = User.objects.get(username='jd') + + def test_org_admin_can_access_activity_log_view(self) -> None: + self.client.force_login(self.org_admin) + + response = self.client.get(reverse('organization_activity_log', args=[self.organization.pk])) + + self.assertEqual(response.status_code, 200) + + def test_non_admin_member_gets_403_on_activity_log_view(self) -> None: + self.client.force_login(self.member) + + response = self.client.get(reverse('organization_activity_log', args=[self.organization.pk])) + + self.assertEqual(response.status_code, 403) + + def test_activity_log_timestamp_uses_german_format_for_all_locales(self) -> None: + log_entry = OrganizationActivityLog.objects.create( + organization=self.organization, + actor=self.org_admin, + action=OrganizationActivityLog.Action.ORGANIZATION_UPDATED, + summary='Timestamp format test', + ) + OrganizationActivityLog.objects.filter(pk=log_entry.pk).update(created_at=datetime(2026, 4, 5, 14, 30, 0)) + + self.client.force_login(self.org_admin) + + with translation.override('en'): + english_response = self.client.get(reverse('organization_activity_log', args=[self.organization.pk])) + with translation.override('de'): + german_response = self.client.get(reverse('organization_activity_log', args=[self.organization.pk])) + + english_content = english_response.content.decode('utf-8', errors='ignore') + german_content = german_response.content.decode('utf-8', errors='ignore') + + pattern = r'.*?\s*([^<]+)' + english_match = re.search(pattern, english_content, re.DOTALL) + german_match = re.search(pattern, german_content, re.DOTALL) + + self.assertIsNotNone(english_match) + self.assertIsNotNone(german_match) + + english_timestamp = english_match.group(1).strip() + german_timestamp = german_match.group(1).strip() + + self.assertEqual(english_timestamp, german_timestamp) + self.assertIn('14:30', english_timestamp) + self.assertIn('2026', english_timestamp) diff --git a/src/shiftings/organizations/urls/organization.py b/src/shiftings/organizations/urls/organization.py index 246c8e7..0338bdd 100644 --- a/src/shiftings/organizations/urls/organization.py +++ b/src/shiftings/organizations/urls/organization.py @@ -2,8 +2,13 @@ from shiftings.cal.feed.organization import OrganizationFeed from shiftings.organizations.views.organization import ( - OrganizationAdminView, OrganizationEditView, OrganizationListView, OrganizationSettingsView, OrganizationShiftsView, - OwnOrganizationListView + OrganizationActivityLogView, + OrganizationAdminView, + OrganizationEditView, + OrganizationListView, + OrganizationSettingsView, + OrganizationShiftsView, + OwnOrganizationListView, ) from shiftings.organizations.views.permission import OrganizationParticipationPermissionEditView @@ -15,6 +20,7 @@ path('my/', OwnOrganizationListView.as_view(), name='own_organizations'), path('/', OrganizationShiftsView.as_view(), name='organization'), path('/admin/', OrganizationAdminView.as_view(), name='organization_admin'), + path('/activity/', OrganizationActivityLogView.as_view(), name='organization_activity_log'), path('/settings/', OrganizationSettingsView.as_view(), name='organization_settings'), path('/update/', OrganizationEditView.as_view(), name='organization_update'), # feed diff --git a/src/shiftings/organizations/views/membership.py b/src/shiftings/organizations/views/membership.py index f7e0905..3a28216 100644 --- a/src/shiftings/organizations/views/membership.py +++ b/src/shiftings/organizations/views/membership.py @@ -1,30 +1,23 @@ from typing import Any -from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin -from django.http import HttpResponse -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.generic import DeleteView from django.views.generic.edit import FormMixin from shiftings.organizations.forms.membership import MembershipForm -from shiftings.organizations.models import Membership, Organization -from shiftings.organizations.views.organization import OrganizationPermissionMixin +from shiftings.organizations.models import Membership +from shiftings.organizations.models.activity_log import OrganizationActivityLog +from shiftings.organizations.services import log_organization_activity +from shiftings.organizations.views.membership_base import MembershipSuccessMessageMixin, OrganizationScopedMembershipMixin from shiftings.utils.views.create_update_view import CreateOrUpdateView -class MembershipViewMixin(OrganizationPermissionMixin): +class MembershipViewMixin(OrganizationScopedMembershipMixin): model = Membership pk_url_kwarg = 'None' permission_required = 'organizations.edit_members' - def get_organization(self) -> Organization: - return self._get_object(Organization, 'org_pk') - - def get_success_url(self): - return reverse('organization_admin', args=[self.get_organization().pk]) - class MembershipAddView(MembershipViewMixin, CreateOrUpdateView): membership_name: str @@ -42,15 +35,42 @@ def get_initial(self) -> dict[str, Any]: initial['type'] = organization.default_membership_type return initial + def form_valid(self, form): + response = super().form_valid(form) + member_name = self.object.user.display if self.object.user else self.object.group.name + log_organization_activity( + organization=self.object.organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.MEMBERSHIP_ADDED, + summary=_('Added member: {member}').format(member=member_name), + metadata={ + 'membership_type': self.object.type.name, + 'group_name': self.object.group.name if self.object.group else '', + }, + ) + return response + -class MembershipRemoveView(MembershipViewMixin, UserPassesTestMixin, DeleteView, FormMixin): +class MembershipRemoveView(MembershipSuccessMessageMixin, MembershipViewMixin, UserPassesTestMixin, DeleteView, FormMixin): pk_url_kwarg = 'member_pk' def test_func(self) -> bool: return not self._get_object(Membership, self.pk_url_kwarg).type.admin \ or self.get_organization().is_admin(self.request.user) - def form_valid(self, form: Any) -> HttpResponse: - result = super().form_valid(form) - messages.success(self.request, _('Membership removed')) - return result + def form_valid(self, form): + organization = self.object.organization + member_name = self.object.user.display if self.object.user else self.object.group.name + membership_type = self.object.type.name + response = super().form_valid(form) + log_organization_activity( + organization=organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.MEMBERSHIP_REMOVED, + summary=_('Removed member: {member}').format(member=member_name), + metadata={ + 'membership_type': membership_type, + 'group_name': self.object.group.name if self.object.group else '', + }, + ) + return response diff --git a/src/shiftings/organizations/views/membership_base.py b/src/shiftings/organizations/views/membership_base.py new file mode 100644 index 0000000..687440d --- /dev/null +++ b/src/shiftings/organizations/views/membership_base.py @@ -0,0 +1,33 @@ +from abc import ABC + +from django.contrib import messages +from django.http import HttpResponse +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from shiftings.organizations.models import Organization +from shiftings.organizations.views.organization_base import OrganizationPermissionMixin + + +class OrganizationScopedMembershipMixin(OrganizationPermissionMixin, ABC): + organization_pk_url_kwarg: str = 'org_pk' + organization_success_view_name: str = 'organization_admin' + use_organization_absolute_success_url: bool = False + + def get_organization(self) -> Organization: + return self._get_object(Organization, self.organization_pk_url_kwarg) + + def get_success_url(self) -> str: + organization = self.get_organization() + if self.use_organization_absolute_success_url: + return organization.get_absolute_url() + return reverse(self.organization_success_view_name, args=[organization.pk]) + + +class MembershipSuccessMessageMixin(ABC): + success_message = _('Membership removed') + + def form_valid(self, form) -> HttpResponse: + response = super().form_valid(form) + messages.success(self.request, self.success_message) + return response diff --git a/src/shiftings/organizations/views/membership_type.py b/src/shiftings/organizations/views/membership_type.py index 0990e3a..a1a71c7 100644 --- a/src/shiftings/organizations/views/membership_type.py +++ b/src/shiftings/organizations/views/membership_type.py @@ -1,35 +1,34 @@ from typing import Any, Dict -from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin -from django.http import HttpResponse from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.generic import DeleteView from django.views.generic.edit import FormMixin from shiftings.organizations.forms.membership import MembershipTypeForm -from shiftings.organizations.models import Organization +from shiftings.organizations.models.activity_log import OrganizationActivityLog from shiftings.organizations.models.membership import MembershipType -from shiftings.organizations.views.organization import OrganizationPermissionMixin +from shiftings.organizations.services import log_organization_activity +from shiftings.organizations.views.membership_base import ( + MembershipSuccessMessageMixin, + OrganizationScopedMembershipMixin, +) from shiftings.utils.views.create_update_view import CreateOrUpdateView -class MembershipTypeViewMixin(OrganizationPermissionMixin): +class MembershipTypeViewMixin(OrganizationScopedMembershipMixin): model = MembershipType permission_required = 'organizations.edit_membership_types' pk_url_kwarg = 'member_pk' - - def get_organization(self) -> Organization: - return self._get_object(Organization, 'org_pk') - - def get_success_url(self) -> str: - return self.get_organization().get_absolute_url() + use_organization_absolute_success_url = True class MembershipTypeEditView(MembershipTypeViewMixin, CreateOrUpdateView): membership_name: str form_class = MembershipTypeForm + use_organization_absolute_success_url = False + organization_success_view_name = 'organization_admin' def get_form_kwargs(self) -> Dict[str, Any]: kwargs = super().get_form_kwargs() @@ -42,17 +41,69 @@ def get_initial(self) -> Dict[str, Any]: initial['organization'] = self.get_organization() return initial - def get_success_url(self): - return reverse('organization_admin', args=[self.get_organization().pk]) + def form_valid(self, form): + is_create = self.is_create() + old_name = None + old_admin = None + old_default = None + if not is_create: + membership_type = self.get_object() + old_name = membership_type.name + old_admin = membership_type.admin + old_default = membership_type.default + + response = super().form_valid(form) + action = OrganizationActivityLog.Action.MEMBERSHIP_TYPE_CREATED if is_create \ + else OrganizationActivityLog.Action.MEMBERSHIP_TYPE_UPDATED + + metadata = { + 'target_url': reverse( + 'membership_type_edit', + kwargs={'org_pk': self.object.organization.pk, 'member_pk': self.object.pk}, + ), + 'target_label': self.object.name, + 'name': self.object.name, + 'admin': self.object.admin, + 'default': self.object.default, + } + if not is_create: + metadata['before'] = { + 'name': old_name, + 'admin': old_admin, + 'default': old_default, + } + log_organization_activity( + organization=self.object.organization, + actor=self.request.user, + action=action, + summary=_('Membership type updated: {name}').format(name=self.object.name) + if not is_create else _('Membership type created: {name}').format(name=self.object.name), + metadata=metadata, + ) + return response -class MembershipTypeRemoveView(MembershipTypeViewMixin, UserPassesTestMixin, DeleteView, FormMixin): +class MembershipTypeRemoveView( + MembershipSuccessMessageMixin, + MembershipTypeViewMixin, + UserPassesTestMixin, + DeleteView, + FormMixin, +): def test_func(self) -> bool: membership_type = self._get_object(MembershipType, self.pk_url_kwarg) return not (membership_type.admin or membership_type.default) - def form_valid(self, form: Any) -> HttpResponse: - result = super().form_valid(form) - messages.success(self.request, _('Membership removed')) - return result + def form_valid(self, form): + organization = self.object.organization + name = self.object.name + response = super().form_valid(form) + log_organization_activity( + organization=organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.MEMBERSHIP_TYPE_REMOVED, + summary=_('Membership type removed: {name}').format(name=name), + metadata={}, + ) + return response diff --git a/src/shiftings/organizations/views/organization.py b/src/shiftings/organizations/views/organization.py index 4c6e69b..e0d92bf 100644 --- a/src/shiftings/organizations/views/organization.py +++ b/src/shiftings/organizations/views/organization.py @@ -3,41 +3,47 @@ from datetime import date from typing import Any -from django.db.models import QuerySet -from django.views.generic import DetailView, ListView +from django.db.models import Q, QuerySet from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView, ListView from shiftings.organizations.forms.membership import MembershipForm from shiftings.organizations.forms.organization import OrganizationForm from shiftings.organizations.models import MembershipType, Organization, OrganizationDummyUser -from shiftings.organizations.views.organization_base import OrganizationMemberMixin, OrganizationPermissionMixin +from shiftings.organizations.models.activity_log import OrganizationActivityLog +from shiftings.organizations.services import build_changed_fields, log_organization_activity +from shiftings.organizations.views.organization_base import ( + OrganizationAdminMixin, + OrganizationMemberMixin, + OrganizationPermissionMixin, +) from shiftings.shifts.forms.summary import OrganizationShiftSummaryForm from shiftings.utils.pagination import get_pagination_context from shiftings.utils.typing import UserRequest from shiftings.utils.views.base import BaseLoginMixin, BasePermissionMixin from shiftings.utils.views.create_update_view import CreateOrUpdateViewWithImageUpload +from shiftings.utils.views.search import SearchableQuerysetMixin -class OrganizationListView(BasePermissionMixin, ListView): +class OrganizationListView(SearchableQuerysetMixin, BasePermissionMixin, ListView): template_name = 'organizations/list.html' model = Organization permission_required = 'organization.admin' context_object_name = 'organizations' + search_fields = ('name',) extra_context = { 'full': True } def get_queryset(self) -> QuerySet[Organization]: - search_param = self.request.GET.get('search_param') - if search_param is not None: - return Organization.objects.filter(name__icontains=search_param) - return Organization.objects.all() + return self.apply_search_filter(Organization.objects.all()) -class OwnOrganizationListView(BaseLoginMixin, ListView): +class OwnOrganizationListView(SearchableQuerysetMixin, BaseLoginMixin, ListView): template_name = 'organizations/list.html' model = Organization context_object_name = 'organizations' + search_fields = ('name',) extra_context = { 'full': False } @@ -45,11 +51,7 @@ class OwnOrganizationListView(BaseLoginMixin, ListView): request: UserRequest def get_queryset(self) -> QuerySet[Organization]: - search_param = self.request.GET.get('search_param') - organizations = self.request.user.organizations - if search_param is not None: - return organizations.filter(name__icontains=search_param) - return organizations + return self.apply_search_filter(self.request.user.organizations.all()) class OrganizationShiftsView(OrganizationMemberMixin, DetailView): @@ -67,10 +69,12 @@ def get_organization(self) -> Organization: def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) today = date.today() - context['shifts'] = get_pagination_context(self.request, - self.object.shifts.filter(start__date__gte=today, - end__date__gte=today), - 5, 'shifts') + context['shifts'] = get_pagination_context( + self.request, + self.object.shifts.filter(start__date__gte=today, end__date__gte=today), + 5, + 'shifts', + ) return context @@ -87,6 +91,7 @@ class OrganizationAdminView(OrganizationPermissionMixin, DetailView): def get_title(self) -> str: return _('{org} Administration').format(org=self.get_object()) + def get_organization(self) -> Organization: return self.get_object() @@ -101,9 +106,12 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: 'form': self.create_membership_form(membership_type) }) context['membership_types'] = membership_types - context['shifts'] = get_pagination_context(self.request, - self.object.shifts.order_by('-start', '-end', 'name').all(), - 25, 'shifts') + context['shifts'] = get_pagination_context( + self.request, + self.object.shifts.order_by('-start', '-end', 'name').all(), + 25, + 'shifts', + ) context['shifts_claimable'] = OrganizationDummyUser.objects.filter(organization=self.object).count() > 0 return context @@ -123,6 +131,33 @@ def has_permission(self): def get_success_url(self) -> str: return self.object.get_absolute_url() + def form_valid(self, form): + tracked_fields = ('name', 'email', 'telephone_number', 'website', 'description', 'confirm_participation_active') + old_values = None + if not self.is_create(): + old_values = { + field_name: getattr(self.get_object(), field_name) + for field_name in tracked_fields + } + + response = super().form_valid(form) + + if old_values is not None: + changed_fields = build_changed_fields(old_values, self.object) + if changed_fields: + log_organization_activity( + organization=self.object, + actor=self.request.user, + action=OrganizationActivityLog.Action.ORGANIZATION_UPDATED, + summary=_('Organization details updated'), + metadata={ + 'target_url': self.object.get_absolute_url(), + 'target_label': self.object.name, + 'changed_fields': changed_fields, + }, + ) + return response + class OrganizationSettingsView(OrganizationPermissionMixin, DetailView): template_name = 'organizations/organization_settings.html' @@ -141,3 +176,45 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context['summary_settings_form'] = OrganizationShiftSummaryForm(instance=self.object.summary_settings) return context + + +class OrganizationActivityLogView(SearchableQuerysetMixin, OrganizationAdminMixin, DetailView): + template_name = 'organizations/organization_activity_log.html' + object: Organization + context_object_name = 'organization' + save_path_in_session = True + search_fields = ('summary', 'action', 'actor__display') + + def get_title(self) -> str: + return _('{org} Activity Log').format(org=self.get_object()) + + def get_organization(self) -> Organization: + return self.get_object() + + def get_activity_log_queryset(self) -> QuerySet[OrganizationActivityLog]: + base_queryset = self.object.activity_logs.select_related('actor').all() + search_param = self.get_search_param() + normalized_search_param = search_param.lower() if search_param else None + + if normalized_search_param and normalized_search_param in ('system', str(_('System')).lower()): + return base_queryset.filter(actor__isnull=True) + + action_query = Q() + for action_value, action_label in OrganizationActivityLog.Action.choices: + if normalized_search_param and normalized_search_param in str(action_label).lower(): + action_query |= Q(action=action_value) + + queryset = self.apply_search_filter(base_queryset) + if action_query != Q(): + queryset = queryset | base_queryset.filter(action_query) + return queryset.distinct() + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + context = super().get_context_data(**kwargs) + context['activity_logs'] = get_pagination_context( + self.request, + self.get_activity_log_queryset(), + 25, + 'activity_logs', + ) + return context diff --git a/src/shiftings/organizations/views/organization_base.py b/src/shiftings/organizations/views/organization_base.py index f5956bf..c950adc 100644 --- a/src/shiftings/organizations/views/organization_base.py +++ b/src/shiftings/organizations/views/organization_base.py @@ -2,6 +2,7 @@ from typing import Any from django.contrib.auth.mixins import UserPassesTestMixin +from django.urls import reverse from shiftings.organizations.models import Organization from shiftings.utils.permissions import has_any_permission @@ -23,6 +24,9 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context['organization'] = self.get_organization() return context + def organization_reverse(self, view_name: str) -> str: + return reverse(view_name, args=[self.get_organization().pk]) + class OrganizationMemberMixin(OrganizationMixin, UserPassesTestMixin, ABC): def test_func(self) -> bool: @@ -42,3 +46,30 @@ def has_permission(self) -> bool: class OrganizationAdminMixin(OrganizationMixin, UserPassesTestMixin, ABC): def test_func(self) -> bool: return self.request.user.has_perm('organizations.admin') or self.get_organization().is_admin(self.request.user) + + +class OrganizationCreateUpdateMixin(ABC): + organization_create_pk_url_kwarg: str = 'org_pk' + organization_initial_field: str = 'organization' + set_organization_initial_on_create_only: bool = True + + def get_organization(self) -> Organization: + if self.is_create(): + return self._get_object(Organization, self.organization_create_pk_url_kwarg) + return self.get_object().organization + + def get_initial(self) -> dict[str, Any]: + initial = super().get_initial() + if not self.set_organization_initial_on_create_only or self.is_create(): + initial[self.organization_initial_field] = self.get_organization() + return initial + + +class OrganizationObjectRedirectMixin(ABC): + organization_success_view_name: str = 'organization_admin' + + def get_organization(self) -> Organization: + return self.get_object().organization + + def get_success_url(self) -> str: + return reverse(self.organization_success_view_name, args=[self.get_organization().pk]) diff --git a/src/shiftings/organizations/views/user.py b/src/shiftings/organizations/views/user.py index f56ffb3..d48c58a 100644 --- a/src/shiftings/organizations/views/user.py +++ b/src/shiftings/organizations/views/user.py @@ -1,7 +1,6 @@ from django.db.models import QuerySet from django.forms import ModelForm from django.http import HttpResponse -from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.views.generic import ListView @@ -38,7 +37,7 @@ def form_valid(self, form: ModelForm) -> HttpResponse: return super().form_valid(form) def get_success_url(self) -> str: - return reverse('claim_user_list', args=[self.get_organization().pk]) + return self.organization_reverse('claim_user_list') class UnclaimUserView(OrganizationPermissionMixin, CreateOrUpdateView[OrganizationDummyUser]): @@ -55,4 +54,4 @@ def form_valid(self, form: ModelForm) -> HttpResponse: return super().form_valid(form) def get_success_url(self) -> str: - return reverse('claim_user_list', args=[self.get_organization().pk]) + return self.organization_reverse('claim_user_list') diff --git a/src/shiftings/settings.py b/src/shiftings/settings.py index 65a21e3..79b2004 100644 --- a/src/shiftings/settings.py +++ b/src/shiftings/settings.py @@ -98,6 +98,7 @@ 'shiftings.utils.context_processors.debug', 'shiftings.utils.context_processors.feature', 'shiftings.utils.context_processors.today', + 'shiftings.utils.context_processors.theme', 'shiftings.organizations.context_processors.organization_permissions', 'django.template.context_processors.debug', 'django.template.context_processors.request', @@ -175,6 +176,7 @@ ['en', 'English'], ] USE_I18N = True +FORMAT_MODULE_PATH = ['shiftings.formats'] LOCALE_PATHS = [ BASE_DIR / 'locale', ] diff --git a/src/shiftings/shifts/admin.py b/src/shiftings/shifts/admin.py index c9defcf..df39a50 100644 --- a/src/shiftings/shifts/admin.py +++ b/src/shiftings/shifts/admin.py @@ -1,16 +1,17 @@ -from django.contrib import admin - from shiftings.shifts.models import ( OrganizationSummarySettings, Participant, ParticipationPermission, RecurringShift, Shift, ShiftTemplate, ShiftTemplateGroup, ShiftType, ShiftTypeGroup ) +from shiftings.utils.admin import register_models -admin.site.register(OrganizationSummarySettings) -admin.site.register(Participant) -admin.site.register(ParticipationPermission) -admin.site.register(RecurringShift) -admin.site.register(Shift) -admin.site.register(ShiftType) -admin.site.register(ShiftTypeGroup) -admin.site.register(ShiftTemplateGroup) -admin.site.register(ShiftTemplate) +register_models( + OrganizationSummarySettings, + Participant, + ParticipationPermission, + RecurringShift, + Shift, + ShiftType, + ShiftTypeGroup, + ShiftTemplateGroup, + ShiftTemplate, +) diff --git a/src/shiftings/shifts/forms/participant.py b/src/shiftings/shifts/forms/participant.py index aaf9303..4f37dd1 100644 --- a/src/shiftings/shifts/forms/participant.py +++ b/src/shiftings/shifts/forms/participant.py @@ -54,7 +54,7 @@ def clean_other_user(self) -> Optional[User]: try: user = User.objects.get(username=username) except User.DoesNotExist as e: - raise ValidationError(_('The user you entered could not be found.')) from e + raise ValidationError(_('The user you entered could not be found. Please try again or try another username.')) from e return user def clean(self) -> Optional[dict[str, Any]]: @@ -77,6 +77,6 @@ def clean(self) -> Optional[dict[str, Any]]: self.add_error('other_user', msg) return cleaned_data if self.shift.is_participant(user): - self.add_error(error_field, _('Cannot add {user} multiple times to this shift').format(user=user)) + self.add_error(error_field, _('Cannot add {user} to this shift. They are already registered.').format(user=user)) cleaned_data['user'] = user return cleaned_data diff --git a/src/shiftings/shifts/forms/permission.py b/src/shiftings/shifts/forms/permission.py index bd40008..ae19262 100644 --- a/src/shiftings/shifts/forms/permission.py +++ b/src/shiftings/shifts/forms/permission.py @@ -34,7 +34,7 @@ def clean(self) -> Dict[str, Any]: if existing and existing.permission_type >= permission: self.add_error('permission_type_field', _('Permission would have no effect. ' - 'Permission for all Users is more extensive: {permission}') + 'Permission for all Users is more extensive: {permission}.') .format(permission=existing.permission_type.label)) return cleaned_data @@ -46,7 +46,7 @@ def clean(self) -> Dict[str, Any]: self.add_error('permission_type_field', _('Permission would have no effect. ' 'There is an inherited more extensive permission: ' - '{referred_object} ({permission})') + '{referred_object} ({permission}).') .format(referred_object=existing.referred_object, permission=existing.permission_type.label)) elif organization is not None: @@ -55,7 +55,7 @@ def clean(self) -> Dict[str, Any]: self.add_error('permission_type_field', _('Permission would have no effect. ' 'There is an inherited more extensive permission for all Users: ' - '{referred_object} ({permission})') + '{referred_object} ({permission}).') .format(referred_object=existing.referred_object, permission=existing.permission_type.label)) @@ -75,7 +75,7 @@ def clean(self) -> None: if organization in organizations: if organization is not None: form.add_error('organization', - _('Can only set one permission for organization "{organization}"') + _('Can only set one permission for organization "{organization}".') .format(organization=organization)) form.add_error('organization', _('Can only set one permission for all Users')) organizations.append(organization) @@ -88,7 +88,7 @@ def clean(self) -> None: and form.cleaned_data['permission_type_field'] <= all_users_permission: form.add_error('permission_type_field', _('Permission would have no effect. ' - 'Permission for all Users is more extensive: {permission}') + 'Permission for all Users is more extensive: {permission}.') .format(permission=all_users_permission.label)) diff --git a/src/shiftings/shifts/forms/recurring.py b/src/shiftings/shifts/forms/recurring.py index 0913e1d..36a0ed1 100644 --- a/src/shiftings/shifts/forms/recurring.py +++ b/src/shiftings/shifts/forms/recurring.py @@ -32,13 +32,13 @@ def clean(self) -> Dict[str, Any]: cleaned_data = super().clean() time_frame_type = TimeFrameType(cleaned_data['time_frame_field']) if cleaned_data['week_day_field'] is None and time_frame_type in TimeFrameType.get_weekday_types(): - self.add_error('week_day_field', _('Weekday can\'t be empty with the chosen time frame type.')) + self.add_error('week_day_field', _('Weekday can\'t be empty with the chosen time frame type. Set it to any value between 1 (Monday) and 7 (Sunday) or choose a different time frame type.')) if cleaned_data['month_field'] is None and time_frame_type in TimeFrameType.get_monthday_types(): - self.add_error('month_field', _('Month can\'t be empty with the chosen time frame type.')) + self.add_error('month_field', _('Month can\'t be empty with the chosen time frame type. Set it to any value between 1 (January) and 12 (December) or choose a different time frame type.')) if cleaned_data['weekend_handling_field'] != ProblemHandling.Ignore and not cleaned_data['weekend_warning']: - self.add_error('weekend_handling_field', _('You need to add a warning for weekend handling.')) + self.add_error('weekend_handling_field', _('Weekend handling is enabled but no warning message is set. Please set a warning message or disable weekend handling.')) if cleaned_data['holiday_handling_field'] != ProblemHandling.Ignore and not cleaned_data['holiday_warning']: - self.add_error('holiday_handling_field', _('You need to add a warning for holiday handling.')) + self.add_error('holiday_handling_field', _('Holiday handling is enabled but no warning message is set. Please set a warning message or disable holiday handling.')) return cleaned_data diff --git a/src/shiftings/shifts/forms/shift.py b/src/shiftings/shifts/forms/shift.py index 147e851..a6575bf 100644 --- a/src/shiftings/shifts/forms/shift.py +++ b/src/shiftings/shifts/forms/shift.py @@ -35,9 +35,17 @@ def clean(self) -> Dict[str, Any]: cleaned_data = super().clean() start = cleaned_data.get('start') end = cleaned_data.get('end') + + # If parsing failed, field-level errors already exist. + # Skip cross-field checks to avoid operating on None values. + if start is None or end is None: + return cleaned_data + if start and end and start > end: - raise ValidationError(_('End time must be after start time')) - + self.add_error('end', ValidationError("")) + self.add_error('start', ValidationError("")) + raise ValidationError(_('End time ({end}) must be after start time ({start}).').format(end=end, start=start)) + ## TODO: raise form error if not valid, but first implement proper error display in template max_length = timedelta(minutes=settings.MAX_SHIFT_LENGTH_MINUTES) if end - start > max_length: diff --git a/src/shiftings/shifts/forms/template.py b/src/shiftings/shifts/forms/template.py index 6d3414d..8ecfa22 100644 --- a/src/shiftings/shifts/forms/template.py +++ b/src/shiftings/shifts/forms/template.py @@ -55,13 +55,13 @@ def __init__(self, template_group: ShiftTemplateGroup, **kwargs): def clean_start_delay(self) -> timedelta: minutes = self.cleaned_data['start_delay'] if minutes is None: - raise ValidationError(_('Start Delay was None please reload and use the slider.')) + raise ValidationError(_('Start Delay was None. Please reload and use the slider.')) return timedelta(minutes=minutes) def clean_duration(self) -> timedelta: minutes = self.cleaned_data['duration'] if minutes is None: - raise ValidationError(_('Duration was None please reload and use the slider.')) + raise ValidationError(_('Duration was None. Please reload and use the slider.')) if minutes > settings.MAX_SHIFT_LENGTH_MINUTES: raise ValidationError(_('Shift is too long, can at most be {max} long').format( max=localize_timedelta(timedelta(minutes=settings.MAX_SHIFT_LENGTH_MINUTES)))) diff --git a/src/shiftings/shifts/forms/type.py b/src/shiftings/shifts/forms/type.py index 7e0823d..9daed85 100644 --- a/src/shiftings/shifts/forms/type.py +++ b/src/shiftings/shifts/forms/type.py @@ -19,5 +19,5 @@ def __init__(self, **kwargs: Any) -> None: def clean_name(self) -> str: name = self.cleaned_data['name'] if name.lower() == 'system': - raise ValidationError(_('Can\'t name a shift type "System".')) + raise ValidationError(_('Can\'t name a shift type "System". This name is reserved.')) return name diff --git a/src/shiftings/shifts/models/permission.py b/src/shiftings/shifts/models/permission.py index 209cb1c..95a0088 100644 --- a/src/shiftings/shifts/models/permission.py +++ b/src/shiftings/shifts/models/permission.py @@ -50,25 +50,25 @@ def get_for_instance(self, instance: models.Model, organization: Organization | None) -> ParticipationPermission | None: return self.filter_instance(instance).filter(organization=organization).first() - def get_best_for_instance_and_user(self, instance: models.Model, user: User) -> ParticipationPermissionType: - if user.has_perm('organizations.admin'): - return ParticipationPermissionType.Participate - best_permission = self.filter_instance(instance) \ - .filter(Q(organization__isnull=True) | Q(organization__in=user.organizations)) \ - .aggregate(best=models.Max('permission_type_field'))['best'] + def _get_best_permission_for_user_from_queryset(self, queryset: QuerySet[ParticipationPermission], + user: User) -> ParticipationPermissionType: + """Extract best permission from a queryset filtered by user's organizations.""" + best_permission = queryset.filter( + Q(organization__isnull=True) | Q(organization__in=user.organizations) + ).aggregate(best=models.Max('permission_type_field'))['best'] if not best_permission: return ParticipationPermissionType.NoPermission return ParticipationPermissionType(best_permission) + def get_best_for_instance_and_user(self, instance: models.Model, user: User) -> ParticipationPermissionType: + if user.has_perm('organizations.admin'): + return ParticipationPermissionType.Participate + return self._get_best_permission_for_user_from_queryset(self.filter_instance(instance), user) + def get_best_for_user(self, user: User, *objects: models.Model) -> ParticipationPermissionType: if user.has_perm('organizations.admin'): return ParticipationPermissionType.Participate - best_permission = self.filter_instances(*objects) \ - .filter(Q(organization__isnull=True) | Q(organization__in=user.organizations)) \ - .aggregate(best=models.Max('permission_type_field'))['best'] - if not best_permission: - return ParticipationPermissionType.NoPermission - return ParticipationPermissionType(best_permission) + return self._get_best_permission_for_user_from_queryset(self.filter_instances(*objects), user) class ParticipationPermission(models.Model): diff --git a/src/shiftings/shifts/models/shift.py b/src/shiftings/shifts/models/shift.py index d9aabd3..009bce8 100644 --- a/src/shiftings/shifts/models/shift.py +++ b/src/shiftings/shifts/models/shift.py @@ -45,10 +45,52 @@ class Shift(ShiftBase): class Meta: default_permissions = () ordering = ['start', 'end', 'name', 'organization'] + indexes = [ + models.Index(fields=['organization', 'start', 'end'], name='shift_org_start_end_idx'), + models.Index(fields=['organization', 'shift_type', 'start'], name='shift_org_type_start_idx'), + ] constraints = [ models.CheckConstraint(condition=Q(start__lte=F('end')), name='shift_start_before_end') ] + def _prefetched_participants(self): + return getattr(self, '_prefetched_objects_cache', {}).get('participants') + + def _participant_count(self) -> int: + if hasattr(self, '_participant_count_cache'): + return self._participant_count_cache + participants = self._prefetched_participants() + if participants is not None: + self._participant_count_cache = len(participants) + else: + self._participant_count_cache = self.participants.count() + return self._participant_count_cache + + def _confirmed_participant_count(self) -> int: + if hasattr(self, '_confirmed_participant_count_cache'): + return self._confirmed_participant_count_cache + participants = self._prefetched_participants() + if participants is not None: + self._confirmed_participant_count_cache = sum(1 for participant in participants if participant.confirmed) + else: + self._confirmed_participant_count_cache = self.participants.filter(confirmed=True).count() + return self._confirmed_participant_count_cache + + @staticmethod + def _get_member_organization_ids(user: User) -> set[int]: + cache_attr = '_shiftings_member_organization_ids' + cached_value = getattr(user, cache_attr, None) + if cached_value is not None: + return cached_value + group_ids = list(user.groups.values_list('pk', flat=True)) + membership_filter = Q(user=user) + if group_ids: + membership_filter |= Q(group_id__in=group_ids) + from shiftings.organizations.models.membership import Membership + organization_ids = set(Membership.objects.filter(membership_filter).values_list('organization_id', flat=True)) + setattr(user, cache_attr, organization_ids) + return organization_ids + def clean(self) -> None: if self.event and self.event.organization != self.organization: raise ValidationError(_('Organization and Event Organization must be identical.')) @@ -79,29 +121,29 @@ def time_display(self) -> str: @property def is_full(self) -> bool: - return self.max_users != 0 and self.participants.all().count() >= self.max_users + return self.max_users != 0 and self._participant_count() >= self.max_users @property def participants_missing(self) -> int: if self.max_users == 0: return 0 - return max(self.max_users - self.participants.all().count(), 0) + return max(self.max_users - self._participant_count(), 0) @property def has_required(self) -> bool: - return self.participants.all().count() >= self.required_users + return self._participant_count() >= self.required_users @property def required_participants_missing(self) -> int: if self.required_users == 0: return 0 - return max(self.required_users - self.participants.all().count(), 0) + return max(self.required_users - self._participant_count(), 0) @property def confirmed_participants(self) -> Optional[int]: if not self.organization.confirm_participation_active: return None - return self.participants.all().count() - self.participants.filter(confirmed=True).count() + return self._participant_count() - self._confirmed_participant_count() @property def email(self) -> str: @@ -127,6 +169,9 @@ def get_slots_display(self) -> Optional[list[tuple[Union[bool, User], bool]]]: return slots def is_participant(self, user: User) -> bool: + participants = self._prefetched_participants() + if participants is not None: + return any(participant.user_id == user.pk for participant in participants) return self.participants.filter(user=user).exists() def get_user_permission(self, user): @@ -135,17 +180,17 @@ def get_user_permission(self, user): return ParticipationPermission.objects.get_best_for_user(user, self, self.event, self.organization) def can_see(self, user: User) -> bool: - if self.is_participant(user) or self.organization.is_member(user): + if self.is_participant(user) or self.organization_id in self._get_member_organization_ids(user): return True return self.get_user_permission(user) >= ParticipationPermissionType.Existence def can_see_details(self, user: User) -> bool: - if self.is_participant(user) or self.organization.is_member(user): + if self.is_participant(user) or self.organization_id in self._get_member_organization_ids(user): return True return self.get_user_permission(user) >= ParticipationPermissionType.ShiftDetails def can_see_participants(self, user: User) -> bool: - if self.is_participant(user) or self.organization.is_member(user): + if self.is_participant(user) or self.organization_id in self._get_member_organization_ids(user): return True return self.get_user_permission(user) >= ParticipationPermissionType.ShiftParticipants diff --git a/src/shiftings/shifts/templates/shifts/create_shift.html b/src/shiftings/shifts/templates/shifts/create_shift.html index 9b5b425..c41abad 100644 --- a/src/shiftings/shifts/templates/shifts/create_shift.html +++ b/src/shiftings/shifts/templates/shifts/create_shift.html @@ -29,6 +29,16 @@

Update Shift "{{ name }} for {{ organization }}"

{% csrf_token %} + {% if form.non_field_errors %} + + {% endif %} {% bootstrap_field form.name %} {% bootstrap_field form.place %}
@@ -87,4 +97,4 @@

{% trans "Create from Template" %}

{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/shifts/templates/shifts/recurring/shift.html b/src/shiftings/shifts/templates/shifts/recurring/shift.html index 6cf6248..8872c7b 100644 --- a/src/shiftings/shifts/templates/shifts/recurring/shift.html +++ b/src/shiftings/shifts/templates/shifts/recurring/shift.html @@ -28,11 +28,8 @@

{{ shift.name }}

{% if org_perms.edit_recurring_shifts %} - - - + {% url 'recurring_shift_delete' shift.pk as recurring_shift_delete_url %} + {% include "template/remove_modal_trigger.html" with tag="a" button_class="btn btn-danger ms-1" href="#confirmRemoveModal" remove_name=shift.name remove_url=recurring_shift_delete_url icon_class="fas fa-trash" %} @@ -109,4 +106,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/shifts/templates/shifts/shift.html b/src/shiftings/shifts/templates/shifts/shift.html index 0a87453..36eb244 100644 --- a/src/shiftings/shifts/templates/shifts/shift.html +++ b/src/shiftings/shifts/templates/shifts/shift.html @@ -12,22 +12,19 @@

{% if not shift.shift_type.is_system or perms.organizations.admin %} - - - + {% url 'shift_update' shift.pk as shift_update_url %} + {% trans "Edit Shift" as edit_shift_title %} + {% include "template/icon_action_button.html" with button_class="btn btn-success px-2 py-1" href=shift_update_url title=edit_shift_title icon_class="fa-solid fa-pen-to-square" %} {% now "Y-m-d" as today %} {% if org_perms.admin or shift.start|date:"Y-m-d" >= today and org_perms.delete_shifts %} - - - + {% url 'shift_delete' shift.pk as shift_delete_url %} + {% trans "Delete Shift" as delete_shift_title %} + {% include "template/icon_action_button.html" with button_class="btn btn-danger px-2 py-1" href=shift_delete_url title=delete_shift_title icon_class="fa-solid fa-trash" %} {% endif %} {% if org_perms.admin %} - - - + {% url 'shift_part_permissions_edit' shift.pk as shift_part_permissions_edit_url %} + {% trans "Edit Permissions" as edit_permissions_title %} + {% include "template/icon_action_button.html" with button_class="btn btn-info px-2 py-1" href=shift_part_permissions_edit_url title=edit_permissions_title icon_class="fa-solid fa-shield-halved" %} {% endif %} {% endif %}
@@ -67,9 +64,9 @@

{% if not shift.is_full %} {% if org_perms.add_non_members_to_shifts or org_perms.add_members_to_shifts %} {% if shift.start.date >= current_date or org_perms.add_to_past_shift %} - - {% trans "Add participant" %} - + {% url 'add_participant_other' shift.pk as add_participant_url %} + {% trans "Add participant" as add_participant_title %} + {% include "template/icon_action_button.html" with button_class="btn btn-outline-success ms-3" href=add_participant_url title=add_participant_title icon_class="fa-solid fa-person-circle-plus me-2" label=add_participant_title %} {% endif %} {% endif %} {% endif %} @@ -92,4 +89,4 @@

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/shifts/templates/shifts/summary/summary.html b/src/shiftings/shifts/templates/shifts/summary/summary.html index c2512cb..1c86975 100644 --- a/src/shiftings/shifts/templates/shifts/summary/summary.html +++ b/src/shiftings/shifts/templates/shifts/summary/summary.html @@ -13,6 +13,9 @@

{% trans "Shift Summary" %} {{ display }}

{% endif %}
+ {% if org_users_only %} + + {% endif %} {% bootstrap_form select_timerange_form show_label=False wrapper_class='' %}
@@ -25,7 +28,7 @@

{% trans "Shift Summary" %} {{ display }}

- {% member_shift_summary organization True %} + {% shift_users_summary organization True %}
{% endblock %} \ No newline at end of file diff --git a/src/shiftings/shifts/templates/shifts/template/group.html b/src/shiftings/shifts/templates/shifts/template/group.html index 7d40f71..0d80de1 100644 --- a/src/shiftings/shifts/templates/shifts/template/group.html +++ b/src/shiftings/shifts/templates/shifts/template/group.html @@ -11,26 +11,19 @@

Shift Template {{ group.name }}

{% if org_perms.edit_shift_templates %} - - - - - - - - - + {% url "shift_template_group_delete" group.pk as shift_template_group_delete_url %} + {% include "template/remove_modal_trigger.html" with tag="a" button_class="btn btn-danger ms-1" href="#confirmRemoveModal" remove_name=group.name remove_url=shift_template_group_delete_url icon_class="fas fa-trash" %} + {% url "shift_template_group_update" group.pk as shift_template_group_update_url %} + {% trans "Edit Template" as edit_template_title %} + {% include "template/icon_action_button.html" with button_class="btn btn-success" href=shift_template_group_update_url title=edit_template_title icon_class="fa-solid fa-pen-to-square" %} + {% url "template_group_update_shifts" group.pk as template_group_update_shifts_url %} + {% trans "Edit Shifts" as edit_shifts_title %} + {% include "template/icon_action_button.html" with button_class="btn btn-success" href=template_group_update_shifts_url title=edit_shifts_title icon_class="fa-solid fa-file-pen" %} {% endif %} {% if org_perms.admin %} - - - + {% url "template_group_update_permissions" group.pk as template_group_update_permissions_url %} + {% trans "Edit Template Permissions" as edit_template_permissions_title %} + {% include "template/icon_action_button.html" with button_class="btn btn-info" href=template_group_update_permissions_url title=edit_template_permissions_title icon_class="fa-solid fa-shield-halved" %} {% endif %}
@@ -71,4 +64,4 @@
{% trans "Recurring Shifts" %}
{% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/shifts/templates/shifts/template/member_shift_summary.html b/src/shiftings/shifts/templates/shifts/template/member_shift_summary.html deleted file mode 100644 index a2ef581..0000000 --- a/src/shiftings/shifts/templates/shifts/template/member_shift_summary.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - {% for group in groups %} - - {% endfor %} - {% if has_others %} - - {% endif %} - - - - {% for member in members %} - - - {% for group in member.groups %} - - {% endfor %} - {% if has_others %} - - {% endif %} - - - {% endfor %} - -
{% trans 'Member' %}{{ group.name }}{{ organization.summary_settings.other_shifts_group_name }}{% trans 'Total' %}
{{ member.name }}{{ group }}{{ member.other }}{{ member.total }}
\ No newline at end of file diff --git a/src/shiftings/shifts/templates/shifts/template/participation_permission_form.html b/src/shiftings/shifts/templates/shifts/template/participation_permission_form.html index d998883..7de7438 100644 --- a/src/shiftings/shifts/templates/shifts/template/participation_permission_form.html +++ b/src/shiftings/shifts/templates/shifts/template/participation_permission_form.html @@ -5,13 +5,8 @@
{% bootstrap_field form.organization show_label=False wrapper_class="" %}
{% bootstrap_field form.permission_type_field show_label=False wrapper_class="" %}
- - - - - - + {% include "template/formset_remove_restore_buttons.html" with form_prefix=form.prefix button_classes="btn btn-danger px-2 py-1" %}
- \ No newline at end of file + diff --git a/src/shiftings/shifts/templates/shifts/template/shift_users_summary.html b/src/shiftings/shifts/templates/shifts/template/shift_users_summary.html new file mode 100644 index 0000000..d87912d --- /dev/null +++ b/src/shiftings/shifts/templates/shifts/template/shift_users_summary.html @@ -0,0 +1,74 @@ +
+ + {% for key, value in request.GET.items %} + {% if key != 'org_users_only' %} + + {% endif %} + {% endfor %} +
+ + +
+ +
+ + + + + {% for group in groups %} + + {% endfor %} + {% if has_others %} + + {% endif %} + + + + {% for member in members %} + + + {% for group in member.groups %} + + {% endfor %} + {% if has_others %} + + {% endif %} + + + {% endfor %} + +
{% trans 'Member' %}{{ group.name }}{{ organization.summary_settings.other_shifts_group_name }}{% trans 'Total' %}
{{ member.name }}{{ group }}{{ member.other }}{{ member.total }}
+ +{% if not show_all_users and has_other_users_section %} +
+
+ {% trans "Other Users" %} ({{ other_users_count }}) +
+ + + {% for member in other_users %} + + + {% for group in member.groups %} + + {% endfor %} + {% if has_others %} + + {% endif %} + + + {% endfor %} + +
{{ member.name }}{{ group }}{{ member.other }}{{ member.total }}
+
+
+
+{% endif %} diff --git a/src/shiftings/shifts/templates/shifts/template/slots_display.html b/src/shiftings/shifts/templates/shifts/template/slots_display.html index e60fd2c..cec3193 100644 --- a/src/shiftings/shifts/templates/shifts/template/slots_display.html +++ b/src/shiftings/shifts/templates/shifts/template/slots_display.html @@ -8,13 +8,8 @@ {% if display %} {% if request.user.pk == display.user.pk or org_perms.remove_others_from_shifts %} {% if org_perms.admin or shift.end.date >= current_date %} - + {% url 'remove_participant' shift.pk display.pk as remove_participant_url %} + {% include "template/remove_modal_trigger.html" with tag="button" button_class="btn border border-2 link-remove w-100 mx-2 text-truncate mw-100" remove_name=display.display remove_url=remove_participant_url success_url=request.path label=display.display %} {% else %} {{ display.display }} {% endif %} @@ -34,4 +29,4 @@ {% endif %} {% endif %} - \ No newline at end of file + diff --git a/src/shiftings/shifts/templates/shifts/template/template_form.html b/src/shiftings/shifts/templates/shifts/template/template_form.html index 80506dd..ca26571 100644 --- a/src/shiftings/shifts/templates/shifts/template/template_form.html +++ b/src/shiftings/shifts/templates/shifts/template/template_form.html @@ -6,12 +6,7 @@

{% trans "Name" %}

{% bootstrap_field form.name show_label=False form_group_class='' %}
- - - - - - + {% include "template/formset_remove_restore_buttons.html" with form_prefix=form.prefix button_classes="btn btn-danger px-2 py-1 ms-auto" %}
@@ -45,4 +40,4 @@

{% trans "Name" %}

{% bootstrap_field form.additional_infos %}
- \ No newline at end of file + diff --git a/src/shiftings/shifts/templatetags/shifts.py b/src/shiftings/shifts/templatetags/shifts.py index 823758d..4bc3ae4 100644 --- a/src/shiftings/shifts/templatetags/shifts.py +++ b/src/shiftings/shifts/templatetags/shifts.py @@ -1,9 +1,10 @@ +from collections import defaultdict from dataclasses import dataclass from datetime import date, datetime, time, timedelta from typing import Any, Optional from django import template -from django.db.models import Q +from django.db.models import Count, Q from django.utils.translation import gettext as _ from shiftings.accounts.models import BaseUser, User @@ -28,14 +29,20 @@ def shift_card(context, shift) -> dict[str, Any]: return context -@register.inclusion_tag('shifts/template/member_shift_summary.html', takes_context=True) -def member_shift_summary(context, org, show_all_users: bool = False, show_future_shifts: bool = True) -> dict[str, Any]: +@register.inclusion_tag('shifts/template/shift_users_summary.html', takes_context=True) +def shift_users_summary(context, org, show_all_users: bool = False, show_future_shifts: bool = True) -> dict[str, Any]: def get_int(name: str, default: int) -> int: try: return int(context['request'].GET.get(name, default)) except ValueError: return default + def get_bool(name: str, default: bool = False) -> bool: + value = context['request'].GET.get(name) + if value is None: + return default + return value.lower() in {'1', 'true', 'yes', 'on'} + try: time_range_type = TimeRangeType(get_int('time_range', org.summary_settings.default_time_range_type)) except ValueError: @@ -48,35 +55,92 @@ def get_int(name: str, default: int) -> int: time_filter &= Q(start__lte=date.today()) other_filter = Q(shift_type__isnull=True) | Q(shift_type__group__isnull=True) groups = list(org.shift_type_groups.all()) + shifts_in_range = org.shifts.filter(time_filter) context['groups'] = groups - context['has_others'] = org.shifts.filter(time_filter, other_filter).exists() - users = org.users - if show_all_users: - user_ids = set(org.shifts.filter(time_filter).values_list('participants__user', flat=True)) - user_ids.discard(None) - filtered_user_ids = set(User.objects.filter(pk__in=user_ids).values_list('pk', flat=True)) - filtered_dummy_users = OrganizationDummyUser.objects.filter(pk__in=user_ids) - filtered_user_ids.update(filtered_dummy_users.filter(claimed_by__isnull=True).values_list('pk', flat=True)) - claimed_ids = filtered_dummy_users.filter(claimed_by__isnull=False).values_list('claimed_by__pk', flat=True) - filtered_user_ids.update(BaseUser.objects.filter(pk__in=claimed_ids).values_list('pk', flat=True)) - users = BaseUser.objects.filter(pk__in=filtered_user_ids) - members = [] + context['has_others'] = shifts_in_range.filter(other_filter).exists() + org_users_only = get_bool('org_users_only', False) + + participant_ids = set(shifts_in_range.values_list('participants__user', flat=True)) + participant_ids.discard(None) + + # Resolve participants to either direct users, unclaimed org dummy users, or their claimed-by users. + participant_user_ids = set(User.objects.filter(pk__in=participant_ids).values_list('pk', flat=True)) + participant_dummy_users = OrganizationDummyUser.objects.filter(pk__in=participant_ids).values('pk', 'claimed_by_id') + claimed_dummy_ids = [] + for dummy_user in participant_dummy_users: + if dummy_user['claimed_by_id'] is None: + participant_user_ids.add(dummy_user['pk']) + else: + claimed_dummy_ids.append(dummy_user['claimed_by_id']) + claimed_ids = set(claimed_dummy_ids) + participant_user_ids.update(BaseUser.objects.filter(pk__in=claimed_ids).values_list('pk', flat=True)) + + org_user_ids = set(org.users.values_list('pk', flat=True)) + + if org_users_only: + users = BaseUser.objects.filter(pk__in=org_user_ids) + elif show_all_users: + users = BaseUser.objects.filter(pk__in=participant_user_ids) + else: + users = BaseUser.objects.filter(pk__in=(participant_user_ids | org_user_ids)) + + participant_to_user_id = {pk: pk for pk in participant_user_ids} + for dummy_user in OrganizationDummyUser.objects.filter(pk__in=participant_ids).values('pk', 'claimed_by_id'): + participant_to_user_id[dummy_user['pk']] = dummy_user['claimed_by_id'] or dummy_user['pk'] + + grouped_counts: dict[int, dict[int, int]] = defaultdict(lambda: defaultdict(int)) + group_ids = [group.pk for group in groups] + for row in shifts_in_range.filter( + participants__user__pk__in=participant_ids, + shift_type__group__pk__in=group_ids, + ).values('participants__user__pk', 'shift_type__group__pk').annotate(total=Count('pk', distinct=True)): + effective_user_id = participant_to_user_id.get(row['participants__user__pk']) + if effective_user_id is None: + continue + grouped_counts[effective_user_id][row['shift_type__group__pk']] += row['total'] + + other_counts: dict[int, int] = defaultdict(int) + for row in shifts_in_range.filter( + other_filter, + participants__user__pk__in=participant_ids, + ).values('participants__user__pk').annotate(total=Count('pk', distinct=True)): + effective_user_id = participant_to_user_id.get(row['participants__user__pk']) + if effective_user_id is None: + continue + other_counts[effective_user_id] += row['total'] + + member_entries = [] for user in users.order_by('username'): - pks = [user.pk] + list(OrganizationDummyUser.objects.filter(claimed_by=user).values_list('pk', flat=True)) - group_amounts = [ - org.shifts.filter(time_filter, participants__user__pk__in=pks, shift_type__group=shift_type_group).count() - for shift_type_group in groups - ] - others_amount = org.shifts.filter(time_filter, other_filter, participants__user__pk__in=pks).count() - members.append({ + group_amounts = [grouped_counts[user.pk].get(shift_type_group.pk, 0) for shift_type_group in groups] + others_amount = other_counts.get(user.pk, 0) + member_entries.append({ 'pk': user.pk, 'name': user.display, + 'is_org_user': user.pk in org_user_ids, 'groups': group_amounts, 'other': others_amount, 'total': sum(group_amounts) + others_amount }) - members.sort(key=lambda member: -member['total']) - context['members'] = members + + participants = [member for member in member_entries if member['total'] > 0] + participants.sort(key=lambda member: (-member['total'], member['name'])) + + if show_all_users: + context['members'] = participants + context['other_users'] = [] + context['other_users_count'] = 0 + context['has_other_users_section'] = False + else: + context['members'] = participants + other_users = [member for member in member_entries if member['is_org_user'] and member['total'] == 0] + context['other_users'] = other_users + context['other_users_count'] = len(other_users) + context['has_other_users_section'] = len(other_users) > 0 + + context['other_participants'] = [] + context['has_other_participants_section'] = False + context['show_all_users'] = show_all_users + context['org_users_only'] = org_users_only return context diff --git a/src/shiftings/shifts/tests/test_participant_views.py b/src/shiftings/shifts/tests/test_participant_views.py new file mode 100644 index 0000000..d520b9f --- /dev/null +++ b/src/shiftings/shifts/tests/test_participant_views.py @@ -0,0 +1,97 @@ +from datetime import datetime + +from django.test import TestCase +from django.urls import reverse + +from shiftings.accounts.models import User +from shiftings.organizations.models import Organization, OrganizationActivityLog +from shiftings.shifts.models import Participant, Shift, ShiftType + + +class ParticipantViewsIntegrationTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.shift_type = ShiftType.objects.filter(organization=self.organization).first() + self.shift = Shift.objects.create( + organization=self.organization, + shift_type=self.shift_type, + name='Participant integration shift', + place='K1 Bar', + start=datetime(2099, 1, 5, 10, 0, 0), + end=datetime(2099, 1, 5, 12, 0, 0), + required_users=1, + max_users=2, + ) + self.admin = User.objects.get(username='bob') + self.member = User.objects.get(username='jd') + + def test_add_self_participant_endpoint_adds_current_user(self) -> None: + self.client.force_login(self.admin) + + response = self.client.post( + reverse('add_participant_self', kwargs={'pk': self.shift.pk}), + {'user': self.admin.pk, 'display_name': ''}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.shift.get_absolute_url()) + self.assertTrue(self.shift.participants.filter(user=self.admin).exists()) + self.assertTrue( + OrganizationActivityLog.objects.filter( + organization=self.organization, + action=OrganizationActivityLog.Action.SHIFT_PARTICIPANT_ADDED_SELF, + ).exists() + ) + + def test_add_other_participant_with_org_user(self) -> None: + self.client.force_login(self.admin) + + response = self.client.post( + reverse('add_participant_other', kwargs={'pk': self.shift.pk}), + {'org_user': self.member.pk, 'other_user': '', 'display_name': ''}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.shift.get_absolute_url()) + self.assertTrue(self.shift.participants.filter(user=self.member).exists()) + self.assertTrue( + OrganizationActivityLog.objects.filter( + organization=self.organization, + action=OrganizationActivityLog.Action.SHIFT_PARTICIPANT_ADDED_OTHER, + ).exists() + ) + + def test_add_other_participant_with_non_member_username(self) -> None: + outsider = User.objects.create_user(username='outsider-user', password='secret') + self.client.force_login(self.admin) + + response = self.client.post( + reverse('add_participant_other', kwargs={'pk': self.shift.pk}), + {'org_user': '', 'other_user': outsider.username, 'display_name': ''}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.shift.get_absolute_url()) + self.assertTrue(self.shift.participants.filter(user=outsider).exists()) + + def test_remove_participant_allows_self_removal_and_uses_success_url_override(self) -> None: + participant = Participant.objects.create(user=self.member, display_name='') + self.shift.participants.add(participant) + self.client.force_login(self.member) + + response = self.client.post( + reverse('remove_participant', kwargs={'pk': self.shift.pk, 'ppk': participant.pk}), + {'success_url': reverse('user_profile')}, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, reverse('user_profile')) + self.assertFalse(Participant.objects.filter(pk=participant.pk).exists()) + self.assertTrue( + OrganizationActivityLog.objects.filter( + organization=self.organization, + action=OrganizationActivityLog.Action.SHIFT_PARTICIPANT_REMOVED, + ).exists() + ) diff --git a/src/shiftings/shifts/tests/test_permission_forms.py b/src/shiftings/shifts/tests/test_permission_forms.py new file mode 100644 index 0000000..f68ef51 --- /dev/null +++ b/src/shiftings/shifts/tests/test_permission_forms.py @@ -0,0 +1,101 @@ +from datetime import datetime + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from shiftings.organizations.models import Organization +from shiftings.shifts.forms.permission import ParticipationPermissionForm, ParticipationPermissionFormSet +from shiftings.shifts.models import ParticipationPermission, ParticipationPermissionType, Shift, ShiftType + + +class ParticipationPermissionFormTest(TestCase): + fixtures = ['user', 'organization', 'shift'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.other_organization = Organization.objects.create(name='Other Org') + self.shift_type = ShiftType.objects.filter(organization=self.organization).first() + self.shift = Shift.objects.create( + organization=self.organization, + shift_type=self.shift_type, + name='Permission shift', + place='K1 Bar', + start=datetime(2024, 1, 3, 10, 0, 0), + end=datetime(2024, 1, 3, 12, 0, 0), + required_users=1, + max_users=2, + ) + + def test_rejects_org_permission_if_all_users_permission_is_more_extensive(self) -> None: + ParticipationPermission.objects.create_for_instance( + self.shift, + organization=None, + permission_type_field=ParticipationPermissionType.ShiftParticipants, + ) + + form = ParticipationPermissionForm( + related_object=self.shift, + data={ + 'organization': self.other_organization.pk, + 'permission_type_field': ParticipationPermissionType.Existence, + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('permission_type_field', form.errors) + + +class ParticipationPermissionFormSetTest(TestCase): + fixtures = ['user', 'organization', 'shift'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.other_organization = Organization.objects.create(name='Formset Org A') + self.third_organization = Organization.objects.create(name='Formset Org B') + self.shift_type = ShiftType.objects.filter(organization=self.organization).first() + self.shift = Shift.objects.create( + organization=self.organization, + shift_type=self.shift_type, + name='Formset shift', + place='K1 Bar', + start=datetime(2024, 1, 4, 10, 0, 0), + end=datetime(2024, 1, 4, 12, 0, 0), + required_users=1, + max_users=2, + ) + content_type = ContentType.objects.get_for_model(self.shift) + self.permission_one = ParticipationPermission.objects.create( + referred_content_type=content_type, + referred_object_id=self.shift.pk, + organization=self.other_organization, + permission_type_field=ParticipationPermissionType.Existence, + ) + self.permission_two = ParticipationPermission.objects.create( + referred_content_type=content_type, + referred_object_id=self.shift.pk, + organization=self.third_organization, + permission_type_field=ParticipationPermissionType.Participate, + ) + + def test_rejects_duplicate_organization_entries(self) -> None: + queryset = ParticipationPermission.objects.filter(pk__in=[self.permission_one.pk, self.permission_two.pk]) + formset = ParticipationPermissionFormSet( + data={ + 'form-TOTAL_FORMS': '2', + 'form-INITIAL_FORMS': '2', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': str(self.permission_one.pk), + 'form-0-organization': str(self.other_organization.pk), + 'form-0-permission_type_field': str(ParticipationPermissionType.Existence.value), + 'form-1-id': str(self.permission_two.pk), + 'form-1-organization': str(self.other_organization.pk), + 'form-1-permission_type_field': str(ParticipationPermissionType.ShiftDetails.value), + }, + queryset=queryset, + form_kwargs={'related_object': self.shift}, + ) + + self.assertFalse(formset.is_valid()) + form_errors = [form.errors for form in formset.forms] + self.assertTrue(any('organization' in errors for errors in form_errors)) diff --git a/src/shiftings/shifts/tests/test_permission_views.py b/src/shiftings/shifts/tests/test_permission_views.py new file mode 100644 index 0000000..557c4a8 --- /dev/null +++ b/src/shiftings/shifts/tests/test_permission_views.py @@ -0,0 +1,65 @@ +from datetime import datetime + +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase +from django.urls import reverse + +from shiftings.accounts.models import User +from shiftings.organizations.models import Organization +from shiftings.shifts.models import ParticipationPermission, ParticipationPermissionType, Shift, ShiftType + + +class ShiftParticipationPermissionViewTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.shift_type = ShiftType.objects.filter(organization=self.organization).first() + self.shift = Shift.objects.create( + organization=self.organization, + shift_type=self.shift_type, + name='Permissions integration shift', + place='K1 Bar', + start=datetime(2024, 1, 6, 10, 0, 0), + end=datetime(2024, 1, 6, 12, 0, 0), + required_users=1, + max_users=2, + ) + self.admin = User.objects.get(username='bob') + + def test_get_permissions_page_for_shift(self) -> None: + self.client.force_login(self.admin) + + response = self.client.get(reverse('shift_part_permissions_edit', kwargs={'pk': self.shift.pk})) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['object'], self.shift) + self.assertEqual(response.context['organization'], self.organization) + + def test_post_updates_existing_permission_and_redirects(self) -> None: + content_type = ContentType.objects.get_for_model(self.shift) + permission = ParticipationPermission.objects.create( + referred_content_type=content_type, + referred_object_id=self.shift.pk, + organization=None, + permission_type_field=ParticipationPermissionType.Existence, + ) + self.client.force_login(self.admin) + + response = self.client.post( + reverse('shift_part_permissions_edit', kwargs={'pk': self.shift.pk}), + { + 'form-TOTAL_FORMS': '1', + 'form-INITIAL_FORMS': '1', + 'form-MIN_NUM_FORMS': '0', + 'form-MAX_NUM_FORMS': '1000', + 'form-0-id': str(permission.pk), + 'form-0-organization': '', + 'form-0-permission_type_field': str(ParticipationPermissionType.Participate.value), + }, + ) + + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.shift.get_absolute_url()) + permission.refresh_from_db() + self.assertEqual(permission.permission_type, ParticipationPermissionType.Participate) diff --git a/src/shiftings/shifts/tests/test_shift_forms.py b/src/shiftings/shifts/tests/test_shift_forms.py new file mode 100644 index 0000000..8b85470 --- /dev/null +++ b/src/shiftings/shifts/tests/test_shift_forms.py @@ -0,0 +1,268 @@ +from datetime import datetime, timedelta + +from django.conf import settings +from django.test import TestCase + +from shiftings.accounts.models import User +from shiftings.shifts.forms.participant import AddOtherParticipantForm, AddSelfParticipantForm +from shiftings.shifts.forms.recurring import RecurringShiftForm +from shiftings.shifts.forms.shift import ShiftForm +from shiftings.shifts.forms.template import ShiftTemplateForm +from shiftings.shifts.models import Participant, Shift, ShiftTemplateGroup, ShiftType +from shiftings.shifts.models.recurring import ProblemHandling +from shiftings.shifts.utils.time_frame import TimeFrameType +from shiftings.organizations.models import Organization +from shiftings.utils.time.week import WeekDay + + +class ShiftFormTest(TestCase): + fixtures = ['user', 'organization', 'shift'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.shift_type = ShiftType.objects.filter(organization=self.organization).first() + + def test_valid_shift_data_passes_validation(self) -> None: + form = ShiftForm( + instance=Shift(organization=self.organization, shift_type=self.shift_type), + data={ + 'name': 'Morning shift', + 'place': 'K1 Bar', + 'organization': self.organization.pk, + 'event': '', + 'shift_type': self.shift_type.pk, + 'start': '2024-01-01 10:00:00', + 'end': '2024-01-01 12:00:00', + 'required_users': 1, + 'max_users': 2, + 'additional_infos': '', + 'locked': False, + }, + ) + + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['start'].hour, 10) + self.assertEqual(form.cleaned_data['end'].hour, 12) + + def test_shift_end_before_start_is_rejected(self) -> None: + form = ShiftForm( + instance=Shift(organization=self.organization, shift_type=self.shift_type), + data={ + 'name': 'Broken shift', + 'place': 'K1 Bar', + 'organization': self.organization.pk, + 'event': '', + 'shift_type': self.shift_type.pk, + 'start': '2024-01-01 12:00:00', + 'end': '2024-01-01 10:00:00', + 'required_users': 1, + 'max_users': 2, + 'additional_infos': '', + 'locked': False, + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('start', form.errors) + self.assertIn('end', form.errors) + + def test_invalid_datetimes_do_not_crash_clean(self) -> None: + form = ShiftForm( + instance=Shift(organization=self.organization, shift_type=self.shift_type), + data={ + 'name': 'Broken datetime shift', + 'place': 'K1 Bar', + 'organization': self.organization.pk, + 'event': '', + 'shift_type': self.shift_type.pk, + 'start': 'invalid-start', + 'end': 'invalid-end', + 'required_users': 1, + 'max_users': 2, + 'additional_infos': '', + 'locked': False, + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('start', form.errors) + self.assertIn('end', form.errors) + + +class RecurringShiftFormTest(TestCase): + fixtures = ['user', 'organization', 'shift'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.template_group = ShiftTemplateGroup.objects.get(pk=1) + + def test_weekly_recurring_shift_validates(self) -> None: + form = RecurringShiftForm( + data={ + 'name': 'Weekly bar shift', + 'organization': self.organization.pk, + 'time_frame_field': TimeFrameType.EveryNthWeekday.value, + 'ordinal': 2, + 'week_day_field': WeekDay.Monday.value, + 'month_field': '', + 'first_occurrence': '2024-01-01', + 'auto_create_days': 7, + 'color': '#FD7E14', + 'template': self.template_group.pk, + 'weekend_handling_field': ProblemHandling.Ignore.value, + 'weekend_warning': '', + 'holiday_handling_field': ProblemHandling.Ignore.value, + 'holiday_warning': '', + }, + initial={'organization': self.organization}, + ) + + self.assertTrue(form.is_valid()) + + def test_weekday_timeframe_requires_weekday(self) -> None: + form = RecurringShiftForm( + data={ + 'name': 'Weekly bar shift', + 'organization': self.organization.pk, + 'time_frame_field': TimeFrameType.EveryNthWeekday.value, + 'ordinal': 2, + 'week_day_field': '', + 'month_field': '', + 'first_occurrence': '2024-01-01', + 'auto_create_days': 7, + 'color': '#FD7E14', + 'template': self.template_group.pk, + 'weekend_handling_field': ProblemHandling.Ignore.value, + 'weekend_warning': '', + 'holiday_handling_field': ProblemHandling.Ignore.value, + 'holiday_warning': '', + }, + initial={'organization': self.organization}, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('week_day_field', form.errors) + + def test_month_timeframe_requires_month(self) -> None: + form = RecurringShiftForm( + data={ + 'name': 'Monthly bar shift', + 'organization': self.organization.pk, + 'time_frame_field': TimeFrameType.NthDayOfASpecificMonth.value, + 'ordinal': 2, + 'week_day_field': '', + 'month_field': '', + 'first_occurrence': '2024-01-01', + 'auto_create_days': 7, + 'color': '#FD7E14', + 'template': self.template_group.pk, + 'weekend_handling_field': ProblemHandling.Ignore.value, + 'weekend_warning': '', + 'holiday_handling_field': ProblemHandling.Ignore.value, + 'holiday_warning': '', + }, + initial={'organization': self.organization}, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('month_field', form.errors) + + +class ShiftTemplateFormTest(TestCase): + fixtures = ['user', 'organization', 'shift'] + + def setUp(self) -> None: + self.template_group = ShiftTemplateGroup.objects.get(pk=1) + self.shift_type = ShiftType.objects.get(pk=1) + + def test_valid_template_form_converts_slider_values_to_timedelta(self) -> None: + form = ShiftTemplateForm( + self.template_group, + data={ + 'name': 'Late shift', + 'shift_type': self.shift_type.pk, + 'start_delay': 15, + 'duration': 120, + 'required_users': 1, + 'max_users': 2, + 'additional_infos': '', + }, + ) + + self.assertTrue(form.is_valid()) + instance = form.save(commit=False) + self.assertEqual(instance.start_delay, timedelta(minutes=15)) + self.assertEqual(instance.duration, timedelta(minutes=120)) + + def test_duration_cannot_exceed_max_shift_length(self) -> None: + form = ShiftTemplateForm( + self.template_group, + data={ + 'name': 'Too long shift', + 'shift_type': self.shift_type.pk, + 'start_delay': 15, + 'duration': settings.MAX_SHIFT_LENGTH_MINUTES + 1, + 'required_users': 1, + 'max_users': 2, + 'additional_infos': '', + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('duration', form.errors) + + +class ParticipantFormTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.shift_type = ShiftType.objects.filter(organization=self.organization).first() + self.shift = Shift.objects.create( + organization=self.organization, + shift_type=self.shift_type, + name='Participant test shift', + place='K1 Bar', + start=datetime(2024, 1, 1, 10, 0, 0), + end=datetime(2024, 1, 1, 12, 0, 0), + required_users=1, + max_users=2, + ) + self.member_user = User.objects.get(username='jd') + self.target_user = User.objects.get(username='bob') + + def test_add_self_participant_rejects_duplicates(self) -> None: + participant = Participant.objects.create(user=self.member_user, display_name='') + self.shift.participants.add(participant) + + form = AddSelfParticipantForm( + self.shift, + data={'user': self.member_user.pk, 'display_name': ''}, + initial={'user': self.member_user}, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('__all__', form.errors) + + def test_add_other_participant_accepts_org_member_choice(self) -> None: + form = AddOtherParticipantForm( + self.shift, + data={'org_user': self.member_user.pk, 'other_user': '', 'display_name': ''}, + ) + + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['user'], self.member_user) + + def test_add_other_participant_rejects_both_user_inputs(self) -> None: + form = AddOtherParticipantForm( + self.shift, + data={ + 'org_user': self.member_user.pk, + 'other_user': self.target_user.username, + 'display_name': '', + }, + ) + + self.assertFalse(form.is_valid()) + self.assertIn('org_user', form.errors) + self.assertIn('other_user', form.errors) diff --git a/src/shiftings/shifts/tests/test_shift_users_summary.py b/src/shiftings/shifts/tests/test_shift_users_summary.py new file mode 100644 index 0000000..41b3ba3 --- /dev/null +++ b/src/shiftings/shifts/tests/test_shift_users_summary.py @@ -0,0 +1,109 @@ +from datetime import datetime, timedelta + +from django.test import RequestFactory, TestCase +from django.urls import reverse + +from shiftings.accounts.models import User +from shiftings.organizations.models import Organization +from shiftings.shifts.models import Participant, Shift +from shiftings.shifts.templatetags.shifts import shift_users_summary + + +class ShiftUsersSummaryTagTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.factory = RequestFactory() + self.organization = Organization.objects.get(pk=1) + self.org_user = User.objects.get(username='jd') + self.outsider = User.objects.create_user(username='summary-outsider', password='secret') + + self.shift = Shift.objects.create( + organization=self.organization, + name='Summary shift', + place='Main Hall', + start=datetime.now() - timedelta(days=1), + end=datetime.now(), + required_users=1, + max_users=4, + ) + self.shift.participants.add(Participant.objects.create(user=self.org_user)) + self.shift.participants.add(Participant.objects.create(user=self.outsider)) + + self.additional_shift = Shift.objects.create( + organization=self.organization, + name='Summary shift 2', + place='Main Hall', + start=datetime.now() - timedelta(hours=12), + end=datetime.now() - timedelta(hours=11), + required_users=1, + max_users=4, + ) + self.additional_shift.participants.add(Participant.objects.create(user=self.org_user)) + + def test_shift_users_summary_splits_active_and_other_users(self) -> None: + request = self.factory.get('/') + request.user = self.org_user + + context = shift_users_summary({'request': request}, self.organization, show_all_users=False, show_future_shifts=True) + + members_by_pk = {member['pk']: member for member in context['members']} + self.assertIn(self.org_user.pk, members_by_pk) + self.assertTrue(members_by_pk[self.org_user.pk]['is_org_user']) + self.assertIn(self.outsider.pk, members_by_pk) + self.assertFalse(members_by_pk[self.outsider.pk]['is_org_user']) + + totals = [member['total'] for member in context['members']] + self.assertEqual(totals, sorted(totals, reverse=True)) + + self.assertEqual(context['other_participants'], []) + + other_users_by_pk = {member['pk']: member for member in context['other_users']} + self.assertIn(self.organization.users.exclude(pk=self.org_user.pk).first().pk, other_users_by_pk) + + def test_shift_users_summary_can_filter_to_org_users_only(self) -> None: + request = self.factory.get('/', {'org_users_only': '1'}) + request.user = self.org_user + + context = shift_users_summary({'request': request}, self.organization, show_all_users=False, show_future_shifts=True) + + member_pks = {member['pk'] for member in context['members']} + self.assertNotIn(self.outsider.pk, member_pks) + self.assertIn(self.org_user.pk, member_pks) + self.assertEqual(context['other_participants'], []) + self.assertGreater(len(context['other_users']), 0) + self.assertGreater(context['other_users_count'], 0) + + def test_shift_users_summary_full_summary_has_no_other_users_section(self) -> None: + request = self.factory.get('/') + request.user = self.org_user + + context = shift_users_summary({'request': request}, self.organization, show_all_users=True, show_future_shifts=True) + + self.assertTrue(context['show_all_users']) + self.assertEqual(context['other_participants'], []) + self.assertEqual(context['other_users'], []) + self.assertEqual(context['other_users_count'], 0) + member_pks = {member['pk'] for member in context['members']} + self.assertIn(self.outsider.pk, member_pks) + self.assertIn(self.org_user.pk, member_pks) + + +class OrganizationShiftSummaryViewTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.admin = User.objects.get(username='bob') + self.client.force_login(self.admin) + + def test_navigation_links_keep_org_users_only_filter(self) -> None: + response = self.client.get( + reverse('organization_shift_summary', kwargs={'pk': self.organization.pk}), + {'org_users_only': '1'}, + ) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context['org_users_only']) + self.assertIn('org_users_only=1', response.context['previous_url']) + self.assertIn('org_users_only=1', response.context['next_url']) diff --git a/src/shiftings/shifts/tests/test_template_views.py b/src/shiftings/shifts/tests/test_template_views.py new file mode 100644 index 0000000..0460ab0 --- /dev/null +++ b/src/shiftings/shifts/tests/test_template_views.py @@ -0,0 +1,22 @@ +from django.test import TestCase +from django.urls import reverse + +from shiftings.accounts.models import User +from shiftings.organizations.models import Organization + + +class ShiftTemplateGroupViewTest(TestCase): + fixtures = ['user', 'organization'] + + def setUp(self) -> None: + self.organization = Organization.objects.get(pk=1) + self.admin = User.objects.get(username='bob') + + def test_create_view_uses_org_pk_from_path(self) -> None: + self.client.force_login(self.admin) + + response = self.client.get(reverse('shift_template_group_create', kwargs={'org_pk': self.organization.pk})) + + self.assertEqual(response.status_code, 200) + self.assertTrue(response.context['is_create']) + self.assertEqual(response.context['organization'], self.organization) diff --git a/src/shiftings/shifts/urls/recurring.py b/src/shiftings/shifts/urls/recurring.py index 8ecc486..b08b3bd 100644 --- a/src/shiftings/shifts/urls/recurring.py +++ b/src/shiftings/shifts/urls/recurring.py @@ -3,14 +3,16 @@ from shiftings.shifts.views.recurring import ( RecurringShiftCreateShiftsView, RecurringShiftDeleteView, RecurringShiftDetailView, RecurringShiftEditView ) +from shiftings.utils.url_patterns import organization_crud_paths urlpatterns = [ path('/', RecurringShiftDetailView.as_view(), name='recurring_shift'), - path('create/', RecurringShiftEditView.as_view(), name='recurring_shift_create'), - path('/update/', RecurringShiftEditView.as_view(), name='recurring_shift_update'), - path('/delete/', RecurringShiftDeleteView.as_view(), name='recurring_shift_delete'), path('/create_shift', RecurringShiftCreateShiftsView.as_view(), name='recurring_create_shifts'), -] +] + organization_crud_paths( + edit_view=RecurringShiftEditView, + delete_view=RecurringShiftDeleteView, + name_prefix='recurring_shift', +) diff --git a/src/shiftings/shifts/urls/shift.py b/src/shiftings/shifts/urls/shift.py index 988cc79..791bcdb 100644 --- a/src/shiftings/shifts/urls/shift.py +++ b/src/shiftings/shifts/urls/shift.py @@ -4,14 +4,17 @@ from shiftings.shifts.views.shift import ( CreateShiftFromTemplateGroup, ShiftDeleteView, ShiftDetailView, ShiftEditView, ShiftOrgSelectView ) +from shiftings.utils.url_patterns import organization_crud_paths urlpatterns = [ path('/', ShiftDetailView.as_view(), name='shift'), - path('create/', ShiftEditView.as_view(), name='shift_create'), path('create//template', CreateShiftFromTemplateGroup.as_view(), name='shift_create_from_template'), path('select_org/', ShiftOrgSelectView.as_view(), name='shift_org_select'), - path('/update/', ShiftEditView.as_view(), name='shift_update'), - path('/delete/', ShiftDeleteView.as_view(), name='shift_delete'), path('/permissions/', ShiftParticipationPermissionEditView.as_view(), name='shift_part_permissions_edit'), path('/participant/', include('shiftings.shifts.urls.participant')) -] +] + organization_crud_paths( + edit_view=ShiftEditView, + delete_view=ShiftDeleteView, + name_prefix='shift', + create_pattern='create/', +) diff --git a/src/shiftings/shifts/urls/templates.py b/src/shiftings/shifts/urls/templates.py index a369744..b9942e7 100644 --- a/src/shiftings/shifts/urls/templates.py +++ b/src/shiftings/shifts/urls/templates.py @@ -4,13 +4,15 @@ ShiftTemplateGroupDeleteView, ShiftTemplateGroupDetailView, ShiftTemplateGroupEditView, TemplateGroupAddShiftsView, TemplateGroupParticipationPermissionEditView ) +from shiftings.utils.url_patterns import organization_crud_paths urlpatterns = [ path('/', ShiftTemplateGroupDetailView.as_view(), name='shift_template_group'), - path('create//', ShiftTemplateGroupEditView.as_view(), name='shift_template_group_create'), - path('/update/', ShiftTemplateGroupEditView.as_view(), name='shift_template_group_update'), - path('/delete/', ShiftTemplateGroupDeleteView.as_view(), name='shift_template_group_delete'), path('/templates/', TemplateGroupAddShiftsView.as_view(), name='template_group_update_shifts'), path('/permissions/', TemplateGroupParticipationPermissionEditView.as_view(), name='template_group_update_permissions'), -] +] + organization_crud_paths( + edit_view=ShiftTemplateGroupEditView, + delete_view=ShiftTemplateGroupDeleteView, + name_prefix='shift_template_group', +) diff --git a/src/shiftings/shifts/urls/type.py b/src/shiftings/shifts/urls/type.py index ec287f0..596aacd 100644 --- a/src/shiftings/shifts/urls/type.py +++ b/src/shiftings/shifts/urls/type.py @@ -1,9 +1,8 @@ -from django.urls import path - from shiftings.shifts.views.type import ShiftTypeDeleteView, ShiftTypeEditView +from shiftings.utils.url_patterns import organization_crud_paths -urlpatterns = [ - path('create//', ShiftTypeEditView.as_view(), name='shift_type_create'), - path('/update/', ShiftTypeEditView.as_view(), name='shift_type_update'), - path('/delete/', ShiftTypeDeleteView.as_view(), name='shift_type_delete'), -] +urlpatterns = organization_crud_paths( + edit_view=ShiftTypeEditView, + delete_view=ShiftTypeDeleteView, + name_prefix='shift_type', +) diff --git a/src/shiftings/shifts/urls/type_group.py b/src/shiftings/shifts/urls/type_group.py index f139d2a..aa0b1e5 100644 --- a/src/shiftings/shifts/urls/type_group.py +++ b/src/shiftings/shifts/urls/type_group.py @@ -4,13 +4,16 @@ ShiftTypeGroupDetailView, ShiftTypeGroupEditView, ShiftTypeGroupListView, ShiftTypeGroupMoveDownView, ShiftTypeGroupMoveUpView, ShiftTypeGroupRemoveView ) +from shiftings.utils.url_patterns import organization_crud_paths urlpatterns = [ path('/', ShiftTypeGroupListView.as_view(), name='shift_type_groups'), - path('create//', ShiftTypeGroupEditView.as_view(), name='shift_type_group_create'), - path('/', ShiftTypeGroupDetailView.as_view(), name='shift_type_group_detail'), - path('/update/', ShiftTypeGroupEditView.as_view(), name='shift_type_group_update'), + path('detail//', ShiftTypeGroupDetailView.as_view(), name='shift_type_group_detail'), path('/move_up/', ShiftTypeGroupMoveUpView.as_view(), name='shift_type_group_move_up'), path('/move_down/', ShiftTypeGroupMoveDownView.as_view(), name='shift_type_group_move_down'), - path('/remove/', ShiftTypeGroupRemoveView.as_view(), name='shift_type_group_remove'), -] +] + organization_crud_paths( + edit_view=ShiftTypeGroupEditView, + delete_view=ShiftTypeGroupRemoveView, + name_prefix='shift_type_group', + delete_segment='remove', +) diff --git a/src/shiftings/shifts/views/helpers.py b/src/shiftings/shifts/views/helpers.py new file mode 100644 index 0000000..9b47e55 --- /dev/null +++ b/src/shiftings/shifts/views/helpers.py @@ -0,0 +1,15 @@ +"""Helper utilities for shift views.""" + +from datetime import date, datetime + +from shiftings.shifts.models import Shift + + +def shift_is_past(shift: Shift) -> bool: + """Check if a shift start time is in the past (before today).""" + return shift.start.date() < date.today() + + +def shift_datetime_is_past(shift: Shift) -> bool: + """Check if a shift start time is in the past (before now).""" + return shift.start < datetime.now() diff --git a/src/shiftings/shifts/views/participant.py b/src/shiftings/shifts/views/participant.py index 57af757..380f8f1 100644 --- a/src/shiftings/shifts/views/participant.py +++ b/src/shiftings/shifts/views/participant.py @@ -1,4 +1,3 @@ -from datetime import date from typing import Any from django.http import HttpResponse @@ -6,13 +5,39 @@ from django.views.generic import DeleteView from shiftings.organizations.models import Organization +from shiftings.organizations.models.activity_log import OrganizationActivityLog +from shiftings.organizations.services import log_organization_activity from shiftings.organizations.views.organization_base import OrganizationPermissionMixin from shiftings.shifts.forms.participant import AddOtherParticipantForm, AddSelfParticipantForm from shiftings.shifts.models import Participant, Shift +from shiftings.shifts.views.helpers import shift_is_past from shiftings.utils.exceptions import Http403 from shiftings.utils.views.create_update_view import CreateView +def get_participant_name(participant: Participant) -> str: + return participant.user.display if participant.user else participant.display_name + + +def log_shift_participant_activity(*, + shift: Shift, + actor, + action: OrganizationActivityLog.Action, + participant: Participant, + summary: str) -> None: + log_organization_activity( + organization=shift.organization, + actor=actor, + action=action, + summary=summary.format(participant=get_participant_name(participant)), + metadata={ + 'target_url': shift.get_absolute_url(), + 'target_label': shift.detailed_display, + 'participant_display_name': participant.display_name, + }, + ) + + class AddOtherParticipantView(OrganizationPermissionMixin, CreateView): model = Participant form_class = AddOtherParticipantForm @@ -35,7 +60,7 @@ def get_form_kwargs(self) -> dict[str, Any]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) shift = self.get_shift() - if shift.start.date() < date.today(): + if shift_is_past(shift): form = context['form'] if not hasattr(form, 'cleaned_data'): form.cleaned_data = {} @@ -44,16 +69,30 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: def form_valid(self, form: AddSelfParticipantForm) -> HttpResponse: shift = self.get_shift() - if (shift.start.date() < date.today() + if (shift_is_past(shift) and not self.request.user.has_perm('organizations.add_to_past_shift', self.get_organization())): - raise Http403() + raise Http403("You don't have permission to add participants to past shifts.") if (not self.request.user.has_perm('organizations.add_non_members_to_shifts', self.get_organization()) and not self.get_organization().is_member(form.cleaned_data['user'])): - raise Http403() + raise Http403("You don't have permission to add non-members to this shift.") + self.save_participant_for_shift(form, shift, OrganizationActivityLog.Action.SHIFT_PARTICIPANT_ADDED_OTHER) + return self.success + + def save_participant_for_shift(self, + form: AddSelfParticipantForm, + shift: Shift, + action: OrganizationActivityLog.Action) -> None: self.object = form.save() shift.participants.add(self.object) shift.save() - return self.success + + log_shift_participant_activity( + shift=shift, + actor=self.request.user, + action=action, + participant=self.object, + summary=str(_('Shift participant added: {participant}')), + ) def get_success_url(self) -> str: return self.get_shift().get_absolute_url() @@ -64,7 +103,7 @@ class AddSelfParticipantView(AddOtherParticipantView): permission_required = 'organizations.participate_in_shift' def has_permission(self) -> bool: - if (self.get_shift().start.date() < date.today() + if (shift_is_past(self.get_shift()) and not self.request.user.has_perm('organizations.add_to_past_shift', self.get_organization())): return False if self.get_shift().can_participate(self.request.user): @@ -78,9 +117,7 @@ def get_initial(self) -> dict[str, Any]: def form_valid(self, form: AddSelfParticipantForm) -> HttpResponse: shift = self.get_shift() - self.object = form.save() - shift.participants.add(self.object) - shift.save() + self.save_participant_for_shift(form, shift, OrganizationActivityLog.Action.SHIFT_PARTICIPANT_ADDED_SELF) return self.success @@ -96,7 +133,7 @@ def get_shift(self) -> Shift: return self._get_object(Shift, 'pk') def has_permission(self) -> bool: - if self.get_shift().start.date() < date.today(): + if shift_is_past(self.get_shift()): return self.get_organization().is_admin(self.request.user) if self.get_object().user.pk == self.request.user.pk: return True @@ -106,3 +143,16 @@ def get_success_url(self) -> str: if self.request.POST.get('success_url'): return str(self.request.POST['success_url']) return self.get_shift().get_absolute_url() + + def form_valid(self, form): + shift = self.get_shift() + participant = self.object + response = super().form_valid(form) + log_shift_participant_activity( + shift=shift, + actor=self.request.user, + action=OrganizationActivityLog.Action.SHIFT_PARTICIPANT_REMOVED, + participant=participant, + summary=str(_('Shift participant removed: {participant}')), + ) + return response diff --git a/src/shiftings/shifts/views/recurring.py b/src/shiftings/shifts/views/recurring.py index ee7072e..721c321 100644 --- a/src/shiftings/shifts/views/recurring.py +++ b/src/shiftings/shifts/views/recurring.py @@ -13,7 +13,14 @@ from django.views.generic.edit import BaseFormView, DeleteView from shiftings.organizations.models import Organization -from shiftings.organizations.views.organization_base import OrganizationMemberMixin, OrganizationPermissionMixin +from shiftings.organizations.models.activity_log import OrganizationActivityLog +from shiftings.organizations.services import build_changed_fields, log_organization_activity +from shiftings.organizations.views.organization_base import ( + OrganizationCreateUpdateMixin, + OrganizationMemberMixin, + OrganizationObjectRedirectMixin, + OrganizationPermissionMixin, +) from shiftings.shifts.forms.recurring import RecurringShiftCreateShiftsForm, RecurringShiftForm from shiftings.shifts.models import RecurringShift from shiftings.utils.views.create_update_view import CreateOrUpdateView @@ -40,37 +47,83 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: return context -class RecurringShiftEditView(OrganizationPermissionMixin, CreateOrUpdateView): +class RecurringShiftEditView(OrganizationCreateUpdateMixin, OrganizationPermissionMixin, CreateOrUpdateView): template_name = 'shifts/recurring/form.html' model = RecurringShift form_class = RecurringShiftForm permission_required = 'organizations.edit_recurring_shifts' - def get_organization(self) -> Organization: - if self.is_create(): - return self._get_object(Organization, 'org_pk') - return self.get_object().organization - def get_initial(self) -> dict[str, Any]: initial = super().get_initial() if self.is_create(): - initial['organization'] = self.get_organization() initial['first_occurrence'] = date.today() return initial def get_success_url(self) -> str: return reverse('recurring_shift', args=[self.object.pk]) - -class RecurringShiftDeleteView(OrganizationPermissionMixin, DeleteView): + def form_valid(self, form): + is_create = self.is_create() + old_values = None + if not is_create: + recurring_shift = self.get_object() + old_values = { + 'template': recurring_shift.template_id, + 'interval': recurring_shift.interval, + 'first_occurrence': recurring_shift.first_occurrence, + 'manually_disabled': recurring_shift.manually_disabled, + } + + response = super().form_valid(form) + if is_create: + log_organization_activity( + organization=self.object.organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.RECURRING_SHIFT_CREATED, + summary=_('Recurring shift created: {name}').format(name=self.object.display), + metadata={ + 'target_url': self.object.get_absolute_url(), + 'target_label': self.object.display, + 'name': self.object.display, + }, + ) + return response + + changed_fields = build_changed_fields(old_values, self.object) + if changed_fields: + log_organization_activity( + organization=self.object.organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.RECURRING_SHIFT_UPDATED, + summary=_('Recurring shift updated: {name}').format(name=self.object.display), + metadata={ + 'target_url': self.object.get_absolute_url(), + 'target_label': self.object.display, + 'changed_fields': changed_fields, + }, + ) + return response + + +class RecurringShiftDeleteView(OrganizationObjectRedirectMixin, OrganizationPermissionMixin, DeleteView): permission_required = 'organizations.edit_shift_templates' model = RecurringShift - def get_organization(self) -> Organization: - return self.get_object().organization - - def get_success_url(self) -> str: - return reverse('organization_admin', args=[self.get_organization().pk]) + def form_valid(self, form): + organization = self.object.organization + recurring_name = self.object.display + response = super().form_valid(form) + log_organization_activity( + organization=organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.RECURRING_SHIFT_REMOVED, + summary=_('Recurring shift removed: {name}').format(name=recurring_name), + metadata={ + 'target_label': recurring_name, + 'name': recurring_name, + }, + ) + return response class RecurringShiftCreateShiftsView(OrganizationPermissionMixin, SingleObjectMixin, BaseFormView): @@ -94,7 +147,21 @@ def get_organization(self) -> Organization: def form_valid(self, form: BaseForm) -> HttpResponse: self.create_date = form.cleaned_data['create_date'] + before_count = self.object.created_shifts.count() self.object.create_shifts(form.cleaned_data['create_date']) + created_count = self.object.created_shifts.count() - before_count + log_organization_activity( + organization=self.object.organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.RECURRING_SHIFT_CREATE_SHIFTS, + summary=_('Generated shifts for recurring shift: {name}').format(name=self.object.display), + metadata={ + 'target_url': self.object.get_absolute_url(), + 'target_label': self.object.display, + 'create_date': str(form.cleaned_data['create_date']), + 'created_count': created_count, + }, + ) messages.success(self.request, _('Created Shifts on {}').format(form.cleaned_data['create_date'])) return super().form_valid(form) diff --git a/src/shiftings/shifts/views/shift.py b/src/shiftings/shifts/views/shift.py index 0f5052d..01f9b41 100644 --- a/src/shiftings/shifts/views/shift.py +++ b/src/shiftings/shifts/views/shift.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import date from typing import Any, Dict, Optional from django.contrib import messages @@ -14,11 +14,18 @@ from django.views.generic.edit import DeleteView, FormView from shiftings.organizations.models import Organization -from shiftings.organizations.views.organization_base import OrganizationPermissionMixin +from shiftings.organizations.models.activity_log import OrganizationActivityLog +from shiftings.organizations.services import build_changed_fields, log_organization_activity +from shiftings.organizations.views.organization_base import ( + OrganizationCreateUpdateMixin, + OrganizationObjectRedirectMixin, + OrganizationPermissionMixin, +) from shiftings.shifts.forms.participant import AddSelfParticipantForm from shiftings.shifts.forms.shift import SelectOrgForm, ShiftForm from shiftings.shifts.forms.template import SelectOrgShiftTemplateGroupForm from shiftings.shifts.models import Shift, ShiftTemplateGroup +from shiftings.shifts.views.helpers import shift_datetime_is_past from shiftings.utils.views.base import BaseLoginMixin from shiftings.utils.views.create_update_view import CreateOrUpdateView @@ -70,7 +77,7 @@ def get_success_url(self): return reverse('shift_create', args=[self.org_id]) + f'?date={self.action_date.strftime("%Y-%m-%d")}' -class ShiftEditView(OrganizationPermissionMixin, CreateOrUpdateView): +class ShiftEditView(OrganizationCreateUpdateMixin, OrganizationPermissionMixin, CreateOrUpdateView): model = Shift form_class = ShiftForm permission_required = 'organizations.edit_shifts' @@ -85,25 +92,16 @@ def has_permission(self) -> bool: return self.request.user.has_perm('organizations.admin') return super().has_permission() - def get_organization(self) -> Organization: - if self.is_create(): - return self._get_object(Organization, 'org_pk') - return self.get_object().organization - def get_initial(self) -> Dict[str, Any]: initial = super().get_initial() if self.is_create(): - initial['organization'] = self.get_organization() initial['start'] = self.request.GET.get('date') return initial def get_obj(self) -> Optional[Shift]: if self.is_create(): return None - obj = super().get_object() - if not isinstance(obj, Shift): - return None - return obj + return self._get_typed_object(Shift) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -121,6 +119,53 @@ def get_context_data(self, **kwargs): def get_success_url(self) -> str: return reverse('shift', args=[self.object.pk]) + def form_valid(self, form): + is_create = self.is_create() + old_values = None + if not is_create: + shift = self.get_object() + old_values = { + 'name': shift.name, + 'start': shift.start, + 'end': shift.end, + 'place': shift.place, + 'required_users': shift.required_users, + 'max_users': shift.max_users, + 'shift_type': shift.shift_type_id, + } + + response = super().form_valid(form) + if is_create: + log_organization_activity( + organization=self.object.organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.SHIFT_CREATED, + summary=_('Shift created: {name}').format(name=self.object.name), + metadata={ + 'target_url': self.object.get_absolute_url(), + 'target_label': self.object.detailed_display, + 'name': self.object.name, + 'start': str(self.object.start), + 'end': str(self.object.end), + }, + ) + return response + + changed_fields = build_changed_fields(old_values, self.object) + if changed_fields: + log_organization_activity( + organization=self.object.organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.SHIFT_UPDATED, + summary=_('Shift updated: {name}').format(name=self.object.name), + metadata={ + 'target_url': self.object.get_absolute_url(), + 'target_label': self.object.detailed_display, + 'changed_fields': changed_fields, + }, + ) + return response + class CreateShiftFromTemplateGroup(OrganizationPermissionMixin, FormView): form_class = SelectOrgShiftTemplateGroupForm @@ -143,27 +188,47 @@ def form_valid(self, form: BaseForm) -> HttpResponse: shift.save() for participation_permission in template_group.participation_permissions.all(): participation_permission.create_copy_for(shift) + log_organization_activity( + organization=self.get_organization(), + actor=self.request.user, + action=OrganizationActivityLog.Action.RECURRING_SHIFT_CREATE_SHIFTS, + summary=_('Created shifts from template group: {group}').format(group=template_group.display), + metadata={ + 'target_url': template_group.get_absolute_url(), + 'target_label': template_group.display, + 'template_group': template_group.display, + 'created_count': len(shifts), + 'create_date': str(form.cleaned_data['date_field']), + }, + ) return super().form_valid(form) def get_success_url(self) -> str: return self.get_organization().get_absolute_url() -class ShiftDeleteView(OrganizationPermissionMixin, DeleteView): +class ShiftDeleteView(OrganizationObjectRedirectMixin, OrganizationPermissionMixin, DeleteView): model = Shift object: Shift permission_required = 'organizations.delete_shifts' template_name = 'generic/delete.html' + organization_success_view_name = 'organization' - def get_organization(self) -> Organization: - return self.get_object().organization - - def delete(self, request, *args, **kwargs): - self.object = self.get_object() - if self.object.start < datetime.now(): - messages.error(request, _('Unable to delete past shifts.')) + def form_valid(self, form): + if shift_datetime_is_past(self.object): + messages.error(self.request, _('Unable to delete past shifts.')) return self.render_to_response(self.get_context_data()) - self.object.delete() - - def get_success_url(self) -> str: - return reverse('organization', args=[self.object.organization.pk]) + organization = self.object.organization + shift_name = self.object.name + response = super().form_valid(form) + log_organization_activity( + organization=organization, + actor=self.request.user, + action=OrganizationActivityLog.Action.SHIFT_REMOVED, + summary=_('Shift removed: {name}').format(name=shift_name), + metadata={ + 'target_label': shift_name, + 'name': shift_name, + }, + ) + return response diff --git a/src/shiftings/shifts/views/summary.py b/src/shiftings/shifts/views/summary.py index 6d8b939..47a7d09 100644 --- a/src/shiftings/shifts/views/summary.py +++ b/src/shiftings/shifts/views/summary.py @@ -24,7 +24,7 @@ def get_organization(self) -> Organization: return self.get_object().organization def get_success_url(self): - return reverse('organization_settings', args=[self.get_organization().pk]) + return self.organization_reverse('organization_settings') class OrganizationShiftSummaryView(OrganizationPermissionMixin, DetailView): @@ -43,13 +43,21 @@ def get_int(name: str, default: int) -> int: except ValueError: return default + def get_bool(name: str, default: bool = False) -> bool: + value = self.request.GET.get(name) + if value is None: + return default + return value.lower() in {'1', 'true', 'yes', 'on'} + def get_url(_date: date) -> str: - return reverse('organization_shift_summary', args=[organization.pk]) + '?' + urlencode( - {'time_range': time_range.value, 'year': _date.year, 'month': _date.month} - ) + params = {'time_range': time_range.value, 'year': _date.year, 'month': _date.month} + if org_users_only: + params['org_users_only'] = '1' + return reverse('organization_shift_summary', args=[organization.pk]) + '?' + urlencode(params) context_data = super().get_context_data(**kwargs) organization = self.get_organization() + org_users_only = get_bool('org_users_only', False) try: time_range = TimeRangeType(get_int('time_range', organization.summary_settings.default_time_range_type)) except ValueError: @@ -76,4 +84,5 @@ def get_url(_date: date) -> str: 'year': year, 'month': month, }) + context_data['org_users_only'] = org_users_only return context_data diff --git a/src/shiftings/shifts/views/template.py b/src/shiftings/shifts/views/template.py index 43f32a7..2247fe7 100644 --- a/src/shiftings/shifts/views/template.py +++ b/src/shiftings/shifts/views/template.py @@ -2,12 +2,15 @@ from django.db.models import QuerySet from django.http import HttpResponse -from django.urls import reverse from django.views.generic import DetailView, TemplateView from shiftings.organizations.models import Organization from shiftings.organizations.views.organization import OrganizationMemberMixin, OrganizationPermissionMixin -from shiftings.organizations.views.organization_base import OrganizationMixin +from shiftings.organizations.views.organization_base import ( + OrganizationCreateUpdateMixin, + OrganizationMixin, + OrganizationObjectRedirectMixin, +) from shiftings.shifts.forms.template import ShiftTemplateFormSet, ShiftTemplateGroupForm from shiftings.shifts.models import ShiftTemplate, ShiftTemplateGroup from shiftings.shifts.views.permission import ParticipationPermissionEditView @@ -20,6 +23,10 @@ class ShiftTemplateGroupMixin(OrganizationMixin): model = ShiftTemplateGroup def get_organization(self) -> Organization: + if 'org_pk' in self.kwargs: + return self._get_object(Organization, 'org_pk') + if 'pk' in self.kwargs: + return self._get_object(ShiftTemplateGroup, 'pk').organization return self._get_object_from_get(Organization, 'org') @@ -31,30 +38,25 @@ def get_organization(self) -> Organization: return self.get_object().organization -class ShiftTemplateGroupEditView(ShiftTemplateGroupMixin, CreateOrUpdateView, OrganizationPermissionMixin): +class ShiftTemplateGroupEditView( + ShiftTemplateGroupMixin, + OrganizationCreateUpdateMixin, + CreateOrUpdateView, + OrganizationPermissionMixin, +): form_class = ShiftTemplateGroupForm permission_required = 'organizations.edit_shift_templates' - - def get_organization(self) -> Organization: - if self.is_create(): - return self._get_object(Organization, 'org_pk') - return self.object.organization - - def get_initial(self) -> dict[str, Any]: - initial = super().get_initial() - initial['organization'] = self.get_organization() - return initial + set_organization_initial_on_create_only = False -class ShiftTemplateGroupDeleteView(ShiftTemplateGroupMixin, OrganizationPermissionMixin, DeleteView): +class ShiftTemplateGroupDeleteView( + ShiftTemplateGroupMixin, + OrganizationPermissionMixin, + OrganizationObjectRedirectMixin, + DeleteView, +): permission_required = 'organizations.edit_shift_templates' - def get_organization(self) -> Organization: - return self.get_object().organization - - def get_success_url(self) -> str: - return reverse('organization_admin', args=[self.get_organization().pk]) - class TemplateGroupAddShiftsView(OrganizationPermissionMixin, ModelFormsetBaseView[ShiftTemplate], TemplateView): model = ShiftTemplate diff --git a/src/shiftings/shifts/views/type.py b/src/shiftings/shifts/views/type.py index cda5d9b..f08e678 100644 --- a/src/shiftings/shifts/views/type.py +++ b/src/shiftings/shifts/views/type.py @@ -1,18 +1,20 @@ -from typing import Any - from django.urls import reverse from django.views.generic import DeleteView -from shiftings.organizations.models import Organization -from shiftings.organizations.views.organization_base import OrganizationAdminMixin, OrganizationPermissionMixin +from shiftings.organizations.views.organization_base import ( + OrganizationAdminMixin, + OrganizationCreateUpdateMixin, + OrganizationObjectRedirectMixin, +) from shiftings.shifts.forms.type import ShiftTypeForm from shiftings.shifts.models import ShiftType from shiftings.utils.views.create_update_view import CreateOrUpdateView -class ShiftTypeEditView(OrganizationAdminMixin, CreateOrUpdateView[ShiftType]): +class ShiftTypeEditView(OrganizationCreateUpdateMixin, OrganizationAdminMixin, CreateOrUpdateView[ShiftType]): model = ShiftType form_class = ShiftTypeForm + set_organization_initial_on_create_only = False def test_func(self) -> bool: test = super().test_func() @@ -20,25 +22,9 @@ def test_func(self) -> bool: return test return self.get_object().name != 'System' - def get_organization(self) -> Organization: - if self.is_create(): - return self._get_object(Organization, 'org_pk') - return self.get_object().organization - - def get_initial(self) -> dict[str, Any]: - initial = super().get_initial() - initial['organization'] = self.get_organization() - return initial - def get_success_url(self): return reverse('organization_admin', args=[self.object.organization.pk]) -class ShiftTypeDeleteView(OrganizationAdminMixin, DeleteView): +class ShiftTypeDeleteView(OrganizationObjectRedirectMixin, OrganizationAdminMixin, DeleteView): model = ShiftType - - def get_organization(self) -> Organization: - return self.get_object().organization - - def get_success_url(self) -> str: - return reverse('organization_admin', args=[self.get_organization().pk]) diff --git a/src/shiftings/shifts/views/type_group.py b/src/shiftings/shifts/views/type_group.py index 3b6a555..4ad58be 100644 --- a/src/shiftings/shifts/views/type_group.py +++ b/src/shiftings/shifts/views/type_group.py @@ -8,7 +8,11 @@ from django.views.generic import DetailView, ListView from shiftings.organizations.models import Organization -from shiftings.organizations.views.organization_base import OrganizationAdminMixin +from shiftings.organizations.views.organization_base import ( + OrganizationAdminMixin, + OrganizationCreateUpdateMixin, + OrganizationObjectRedirectMixin, +) from shiftings.shifts.forms.type_group import ShiftTypeGroupForm from shiftings.shifts.models import ShiftTypeGroup from shiftings.utils.views.create_update_view import CreateOrUpdateView @@ -37,18 +41,13 @@ def get_organization(self) -> Organization: return self.object.organization -class ShiftTypeGroupEditView(OrganizationAdminMixin, CreateOrUpdateView[ShiftTypeGroup]): +class ShiftTypeGroupEditView(OrganizationCreateUpdateMixin, OrganizationAdminMixin, CreateOrUpdateView[ShiftTypeGroup]): model = ShiftTypeGroup form_class = ShiftTypeGroupForm - - def get_organization(self) -> Organization: - if self.is_create(): - return self._get_object(Organization, 'org_pk') - return self.get_object().organization + set_organization_initial_on_create_only = False def get_initial(self) -> dict[str, Any]: initial = super().get_initial() - initial['organization'] = self.get_organization() if not self.is_create(): initial['shift_types'] = self.get_object().shift_types.all() return initial @@ -71,14 +70,9 @@ def get_success_url(self): return reverse('organization_settings', args=[self.get_organization().pk]) -class ShiftTypeGroupRemoveView(OrganizationAdminMixin, DeleteView): +class ShiftTypeGroupRemoveView(OrganizationObjectRedirectMixin, OrganizationAdminMixin, DeleteView): model = ShiftTypeGroup - - def get_organization(self) -> Organization: - return self.get_object().organization - - def get_success_url(self) -> str: - return reverse('organization_settings', args=[self.get_organization().pk]) + organization_success_view_name = 'organization_settings' class ShiftTypeGroupMoveView(OrganizationAdminMixin, CreateOrUpdateView[ShiftTypeGroup]): diff --git a/src/shiftings/static/css/base.css b/src/shiftings/static/css/base.css index 839d25b..250d3c2 100644 --- a/src/shiftings/static/css/base.css +++ b/src/shiftings/static/css/base.css @@ -39,19 +39,128 @@ --bs-dark-rgb: 21, 22, 24; --bs-white-rgb: 255, 255, 255; --bs-black-rgb: 0, 0, 0; - --bs-body-color-rgb: 255, 255, 255; - --bs-body-bg-rgb: 255, 255, 255; + --bs-body-color-rgb: 33, 37, 41; + --bs-body-bg-rgb: 248, 249, 250; --bs-body-font-size: 1rem; --bs-body-font-weight: 400; --bs-body-line-height: 1.5; + --bs-body-color: #212529; + --bs-body-bg: #e6eaee; + --bs-menu-bg: #ced5dc; + --bs-form-control-bg: #f2f5f7; + --bs-form-control-color: #212529; + --bs-surface-bg: #eceff2; + --bs-surface-color: #212529; + --bs-surface-striped-bg: rgba(0, 0, 0, .04); + --organization-logo-bg: #cfd7dd; + --organization-panel-bg: #d5dde3; + --set-display-name-color: #0b7285; + --set-display-name-border: #0b7285; + --set-display-name-bg-hover: #c9dee4; + --member-summary-own-bg: rgba(13, 110, 253, .12); + --filter-surface-bg: #e6ecf0; + --filter-surface-border: #c2cad0; + --org-filter-border: rgba(33, 37, 41, .07); + --login-surface-bg: #e1e7eb; + --login-surface-text: #212529; + --theme-border-dark: #6c757d; + --theme-border-light: #b5bdc4; + --theme-btn-dark-bg: #495057; + --theme-btn-dark-border: #495057; + --theme-btn-dark-color: #fff; + --table-head-bg: #d5dce2; + --table-head-color: #212529; + --bs-form-select-valid-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); +} + +[data-bs-theme='dark'] { + --bs-body-color-rgb: 255, 255, 255; + --bs-body-bg-rgb: 33, 37, 41; --bs-body-color: #fff; --bs-body-bg: #212529; --bs-menu-bg: #0b0c0e; + --bs-form-control-bg: #212529; + --bs-form-control-color: #fff; + --bs-surface-bg: #343a40; + --bs-surface-color: #fff; + --bs-surface-striped-bg: rgba(255, 255, 255, .06); + --organization-logo-bg: #4d5459; + --organization-panel-bg: #4d5459; + --set-display-name-color: #0dcaf0; + --set-display-name-border: #0dcaf0; + --set-display-name-bg-hover: rgba(13, 202, 240, .2); + --member-summary-hover-bg: rgba(255, 255, 255, .16); + --member-summary-own-bg: rgba(13, 202, 240, .34); + --filter-surface-bg: #212529; + --filter-surface-border: #4d5459; + --org-filter-border: rgba(255, 255, 255, .14); + --login-surface-bg: #4d5459; + --login-surface-text: #fff; + --theme-border-dark: #212529; + --theme-border-light: #f8f9fa; + --theme-btn-dark-bg: #212529; + --theme-btn-dark-border: #212529; + --theme-btn-dark-color: #fff; + --table-head-bg: #40464d; + --table-head-color: #fff; + --bs-form-select-valid-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); +} + +.bg-dark { + background-color: var(--bs-surface-bg) !important; + color: var(--bs-surface-color) !important; +} + +.table-dark { + --bs-table-bg: var(--bs-surface-bg); + --bs-table-color: var(--bs-surface-color); + --bs-table-striped-bg: var(--bs-surface-striped-bg); + --bs-table-striped-color: var(--bs-surface-color); + --bs-table-border-color: var(--bs-gray-700); +} + +.dropdown-menu, +.dropdown-menu-dark { + --bs-dropdown-bg: var(--bs-surface-bg); + --bs-dropdown-color: var(--bs-surface-color); + --bs-dropdown-link-color: var(--bs-surface-color); + --bs-dropdown-link-hover-color: var(--bs-surface-color); + --bs-dropdown-link-hover-bg: var(--bs-secondary); +} + +.dropdown-menu { + background-color: var(--bs-dropdown-bg); + color: var(--bs-dropdown-color); +} + +.navbar-dark .navbar-brand, +.navbar-dark .navbar-nav .nav-link { + color: var(--bs-body-color); } -input, textarea, select, .form-control, .form-select { - background-color: var(--bs-dark) !important; - color: var(--bs-body-color) !important; +.border-dark { + border-color: var(--theme-border-dark) !important; +} + +.border-light { + border-color: var(--theme-border-light) !important; +} + +.btn-dark { + background-color: var(--theme-btn-dark-bg); + border-color: var(--theme-btn-dark-border); + color: var(--theme-btn-dark-color); +} + +.btn-dark:hover, +.btn-dark:focus { + filter: brightness(.95); + color: var(--theme-btn-dark-color); +} + +input:not([type='checkbox']):not([type='radio']), textarea, select, .form-control, .form-select { + background-color: var(--bs-form-control-bg) !important; + color: var(--bs-form-control-color) !important; } input:disabled { @@ -62,6 +171,15 @@ nav .dropdown { z-index: 99999; } +header { + position: relative; + z-index: 1060; +} + +footer { + z-index: 1060; +} + /*pagination*/ .page-item.disabled .page-link { @@ -97,6 +215,45 @@ nav .dropdown { border-color: var(--bs-body-color) !important; } +.form-check-input:checked { + background-color: var(--bs-success) !important; + border-color: var(--bs-success) !important; +} + +[data-bs-theme='light'] .form-check-input:focus { + box-shadow: 0 0 0 .25rem rgba(var(--bs-success-rgb), .25); +} + +.form-control.is-valid:not([required]), +.form-control-color.is-valid:not([required]) { + border-color: var(--bs-gray-700); + box-shadow: none; + background-image: none; + padding-right: .75rem; +} + +.form-select.is-valid { + background-image: var(--bs-form-select-valid-icon); + background-position: right .75rem center; + background-size: 16px 12px; +} + +.form-select.is-valid:not([required]) { + border-color: var(--bs-gray-700); + box-shadow: none; + padding-right: 2.25rem; +} + +.form-check-input.is-valid:not([required]) { + border-color: var(--bs-body-color) !important; + background-color: transparent; + box-shadow: none; +} + +.form-check-input.is-valid:not([required]) ~ .form-check-label { + color: var(--bs-body-color) !important; +} + .plain-link, .link, .link-remove { cursor: pointer; text-decoration: none; @@ -131,6 +288,25 @@ nav .dropdown { min-width: 200px; } +table thead th, +table thead td, +.table thead th, +.table thead td { + background-color: var(--table-head-bg); + color: var(--table-head-color); + border-color: var(--theme-border-light); +} + +.menu .navbar-brand, +.menu .nav-link { + color: var(--bs-body-color); +} + +.menu .navbar-brand:hover, +.menu .nav-link:hover { + color: var(--bs-teal); +} + .search-menu { width: 20rem; max-width: 250px; @@ -157,8 +333,8 @@ nav .dropdown { } .search-bar:hover { - color: white; - border-color: white; + color: var(--bs-body-color); + border-color: var(--bs-body-color); } .search-bar:focus-within { @@ -201,6 +377,16 @@ nav .dropdown { height: 100px; } +.login-surface.bg-secondary, +.login-option.bg-secondary { + background-color: var(--login-surface-bg) !important; + color: var(--login-surface-text) !important; +} + +.login-option.text-light { + color: var(--login-surface-text) !important; +} + .login-option:hover { -webkit-transition: width 4s ease-in-out; -moz-transition: width 4s ease-in-out; @@ -369,11 +555,40 @@ dl.detail-horizontal dd { display: flex; justify-content: center; align-content: center; - background-color: var(--bs-gray-700); + background-color: var(--organization-logo-bg); border-left: 2px solid var(--bs-body-color); border-bottom: 2px solid var(--bs-body-color); } +.organization-content .bg-secondary { + background-color: var(--organization-panel-bg) !important; +} + +.organization-content .card-header, +.organization-content .organization-url, +.organization-content .organization-contact { + background-color: var(--organization-panel-bg); +} + +.member-group-accordion .accordion-item.bg-secondary, +.member-group-accordion .accordion-header.bg-secondary, +.member-group-accordion .accordion-collapse.bg-dark, +.member-group-accordion .bg-dark.border-start { + background-color: var(--organization-panel-bg) !important; + color: var(--bs-body-color) !important; +} + +.set-display-name-btn { + color: var(--set-display-name-color) !important; + border-color: var(--set-display-name-border) !important; +} + +.set-display-name-btn:hover { + color: var(--set-display-name-color) !important; + border-color: var(--set-display-name-border) !important; + background-color: var(--set-display-name-bg-hover) !important; +} + .organization-logo img { margin: auto; max-width: 100%; @@ -447,27 +662,55 @@ dl.detail-horizontal dd { .filter-accordion { --bs-accordion-color: var(--bs-body-color); - --bs-accordion-bg: transparent; - --bs-accordion-border-color: transparent; + --bs-accordion-bg: var(--filter-surface-bg); + --bs-accordion-border-color: var(--filter-surface-border); --bs-accordion-btn-color: var(--bs-body-color); - --bs-accordion-btn-bg: transparent; - --bs-accordion-btn-focus-border-color: transparent; + --bs-accordion-btn-bg: var(--filter-surface-bg); + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5 L8 11 L14 5'/%3e%3c/svg%3e"); + --bs-accordion-btn-focus-border-color: var(--filter-surface-border); --bs-accordion-btn-focus-box-shadow: unset; --bs-accordion-btn-active-icon: var(--bs-accordion-btn-icon); --bs-accordion-active-color: var(--bs-body-color); - --bs-accordion-active-bg: transparent; + --bs-accordion-active-bg: var(--filter-surface-bg); +} + +[data-bs-theme='dark'] .filter-accordion { + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5 L8 11 L14 5'/%3e%3c/svg%3e"); +} +.filter-accordion .accordion-item, +.filter-accordion .accordion-body, +.filter-accordion .accordion-collapse { + background-color: var(--filter-surface-bg); +} + +#orgFilter .accordion-header.bg-secondary, +#orgFilter .accordion-collapse { + border-color: var(--org-filter-border) !important; + border-width: 1px !important; +} + +.filter-accordion .accordion-header.bg-secondary, +.filter-accordion .accordion-button { + background-color: var(--filter-surface-bg) !important; + color: var(--bs-body-color); +} + +.member-group-accordion .accordion-item.bg-secondary, +.member-group-accordion .accordion-header.bg-secondary { + background-color: var(--filter-surface-bg) !important; + color: var(--bs-body-color); } .popover-body { - background-color: var(--bs-dark); + background-color: var(--bs-body-bg); --bs-popover-body-color: var(--bs-body-color); } .shift-info-popover { --bs-popover-max-width: 300px; - --bs-popover-border-color: var(--bs-dark); + --bs-popover-border-color: var(--bs-secondary); --bs-popover-header-bg: var(--bs-secondary); --bs-popover-body-padding-x: 1rem; --bs-popover-body-padding-y: .5rem; @@ -487,6 +730,10 @@ dl.detail-horizontal dd { white-space: nowrap; } +.member-shift-summary-table .table-active > * { + background-color: var(--member-summary-own-bg) !important; +} + td.disabled { opacity: 5%; } @@ -527,15 +774,3 @@ td.disabled { border: 1px solid var(--bs-body-color); } -/* -Easy (hopefully temporary) fix for issue, where backdrop was rendered above modal on IOS devices using safari browser. -This caused users on IOS to be soft locked out of the modal, as they could not interact with it. - -All positive z-index values (including 0) were also causing the issue. -Changing z-index to -1 makes sure backdrop is always behind the modal. -Changing the position of the modal to anything other than fixed also seemed to fix the issue. -*/ -/* TODO: Remove once this is not required anymore */ -.modal-backdrop { - z-index: -1 !important; -} diff --git a/src/shiftings/static/js/tests/dom-harness.cjs b/src/shiftings/static/js/tests/dom-harness.cjs new file mode 100644 index 0000000..c5f1764 --- /dev/null +++ b/src/shiftings/static/js/tests/dom-harness.cjs @@ -0,0 +1,304 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); + +function createClassList(initialClasses = []) { + const classes = new Set(initialClasses); + return { + add(...classNames) { + for (const className of classNames) { + classes.add(className); + } + }, + remove(...classNames) { + for (const className of classNames) { + classes.delete(className); + } + }, + toggle(className, force) { + if (force === undefined) { + if (classes.has(className)) { + classes.delete(className); + return false; + } + classes.add(className); + return true; + } + if (force) { + classes.add(className); + } else { + classes.delete(className); + } + return force; + }, + contains(className) { + return classes.has(className); + }, + toArray() { + return Array.from(classes); + }, + }; +} + +function matchesSelector(element, selector) { + const attributeMatch = selector.match(/^\[([^=\]]+)=([^\]]+)\]$/); + if (attributeMatch) { + const attributeName = attributeMatch[1]; + const attributeValue = attributeMatch[2].replace(/^['"]|['"]$/g, ''); + const attribute = element.attributes?.[attributeName]; + return element[attributeName] === attributeValue || attribute === attributeValue || attribute?.nodeValue === attributeValue; + } + return false; +} + +function walk(element, callback) { + callback(element); + for (const child of element.children) { + walk(child, callback); + } +} + +function createElement({ + id = '', + tagName = 'DIV', + classNames = [], + dataset = {}, + value = '', + innerText = '', + textContent = '', + name = '', + type = '', + attributes = {}, +} = {}) { + const listeners = {}; + const element = { + id, + tagName: String(tagName).toUpperCase(), + classList: createClassList(classNames), + dataset, + children: [], + parentNode: null, + parentElement: null, + style: {}, + value, + innerText, + textContent, + name, + type, + attributes, + reportValidity() { + return true; + }, + focusCalled: false, + focus() { + element.focusCalled = true; + }, + addEventListener(eventName, callback) { + listeners[eventName] = callback; + }, + dispatchEvent(event) { + const dispatchedEvent = event || { type: undefined }; + if (!dispatchedEvent.target) { + dispatchedEvent.target = element; + } + dispatchedEvent.currentTarget = element; + if (listeners[dispatchedEvent.type]) { + listeners[dispatchedEvent.type](dispatchedEvent); + } + if (dispatchedEvent.bubbles && global.document && global.document !== element.ownerDocument) { + global.document.dispatchEvent(dispatchedEvent); + } + return true; + }, + appendChild(child) { + child.parentNode = element; + child.parentElement = element; + child.ownerDocument = element.ownerDocument; + element.children.push(child); + if (global.document && typeof global.document._registerTree === 'function') { + global.document._registerTree(child); + } + return child; + }, + getElementsByClassName(className) { + const result = []; + walk(element, (node) => { + if (node.classList.contains(className)) { + result.push(node); + } + }); + return result; + }, + getElementsByName(searchName) { + const result = []; + walk(element, (node) => { + if (node.name === searchName) { + result.push(node); + } + }); + return result; + }, + querySelectorAll(selector) { + const result = []; + if (selector.startsWith('.')) { + const className = selector.slice(1); + walk(element, (node) => { + if (node.classList.contains(className)) { + result.push(node); + } + }); + } + return result; + }, + querySelector(selector) { + let found = null; + walk(element, (node) => { + if (!found && matchesSelector(node, selector)) { + found = node; + } + }); + return found; + }, + getAttribute(attributeName) { + const attribute = attributes[attributeName]; + return attribute?.nodeValue ?? attribute; + }, + setAttribute(attributeName, attributeValue) { + attributes[attributeName] = { nodeValue: attributeValue }; + }, + }; + return element; +} + +function createDocument({ root = createElement({ tagName: 'HTML', id: 'root' }), locationHref = 'about:blank' } = {}) { + const elementsById = new Map(); + const listeners = {}; + const document = { + documentElement: root, + location: { href: locationHref }, + addEventListener(eventName, callback) { + listeners[eventName] = callback; + }, + dispatchEvent(event) { + const dispatchedEvent = typeof event === 'string' ? { type: event } : event; + if (!dispatchedEvent.target) { + dispatchedEvent.target = document; + } + dispatchedEvent.currentTarget = document; + if (listeners[dispatchedEvent.type]) { + listeners[dispatchedEvent.type](dispatchedEvent); + } + return true; + }, + getElementById(id) { + return elementsById.get(id) || null; + }, + getElementsByClassName(className) { + const result = []; + walk(root, (node) => { + if (node.classList.contains(className)) { + result.push(node); + } + }); + return result; + }, + getElementsByName(searchName) { + const result = []; + walk(root, (node) => { + if (node.name === searchName) { + result.push(node); + } + }); + return result; + }, + querySelectorAll(selector) { + const result = []; + if (selector.startsWith('.')) { + const className = selector.slice(1); + walk(root, (node) => { + if (node.classList.contains(className)) { + result.push(node); + } + }); + } + return result; + }, + createElement(tagName) { + const element = createElement({ tagName }); + element.ownerDocument = document; + return element; + }, + _registerTree(element) { + walk(element, (node) => { + node.ownerDocument = document; + if (node.id) { + elementsById.set(node.id, node); + } + }); + }, + }; + root.ownerDocument = document; + document._registerTree(root); + return document; +} + +class MockMutationObserver { + constructor(callback) { + this.callback = callback; + this.target = null; + this.options = null; + MockMutationObserver.instances.push(this); + } + + observe(target, options) { + this.target = target; + this.options = options; + } + + trigger(records = []) { + this.callback(records, this); + } +} + +MockMutationObserver.instances = []; + +class MockCustomEvent { + constructor(type, init = {}) { + this.type = type; + Object.assign(this, init); + } +} + +function installDomGlobals(document, extras = {}) { + global.document = document; + global.window = extras.window || {}; + global.CustomEvent = extras.CustomEvent || MockCustomEvent; + global.MutationObserver = extras.MutationObserver || MockMutationObserver; + if (extras.saveAndRestore) { + global.saveAndRestore = extras.saveAndRestore; + } + return document; +} + +function teardownDomGlobals() { + delete global.document; + delete global.window; + delete global.CustomEvent; + delete global.MutationObserver; + delete global.localStorage; + delete global.saveAndRestore; +} + +function loadScript(scriptPath) { + const scriptContent = fs.readFileSync(scriptPath, 'utf8'); + vm.runInThisContext(scriptContent, { filename: scriptPath }); +} + +module.exports = { + MockCustomEvent, + MockMutationObserver, + createDocument, + createElement, + installDomGlobals, + loadScript, + teardownDomGlobals, +}; diff --git a/src/shiftings/static/js/tests/formset.test.cjs b/src/shiftings/static/js/tests/formset.test.cjs new file mode 100644 index 0000000..39d4e71 --- /dev/null +++ b/src/shiftings/static/js/tests/formset.test.cjs @@ -0,0 +1,322 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const scriptPath = path.resolve(__dirname, '..', 'formset.js'); +const scriptContent = fs.readFileSync(scriptPath, 'utf8'); + +function makeClassList(initialClasses = []) { + const classes = new Set(initialClasses); + return { + add(...classNames) { + for (const className of classNames) { + classes.add(className); + } + }, + remove(...classNames) { + for (const className of classNames) { + classes.delete(className); + } + }, + toggle(className, force) { + if (force === undefined) { + if (classes.has(className)) { + classes.delete(className); + return false; + } + classes.add(className); + return true; + } + if (force) { + classes.add(className); + } else { + classes.delete(className); + } + return force; + }, + contains(className) { + return classes.has(className); + }, + }; +} + +function makeElement({ id = '', tagName = 'DIV', classNames = [], innerHTML = '', value = '' } = {}) { + const listeners = {}; + const element = { + id, + tagName, + classList: makeClassList(classNames), + innerHTML, + value, + parentNode: null, + children: [], + style: {}, + addEventListener(eventName, callback) { + listeners[eventName] = callback; + }, + click() { + const callback = listeners.click; + if (callback) { + callback({ currentTarget: element }); + } + }, + appendChild(child) { + if (child && child.isFragment) { + for (const node of child.children) { + element.appendChild(node); + } + return child; + } + child.parentNode = element; + child.parentElement = element; + element.children.push(child); + if (global.document && typeof global.document._registerTree === 'function') { + global.document._registerTree(child); + } + return child; + }, + insertAdjacentElement(position, node) { + if (position === 'beforeend') { + return element.appendChild(node); + } + return null; + }, + insertBefore(node, referenceNode) { + const index = element.children.indexOf(referenceNode); + if (index < 0) { + return element.appendChild(node); + } + node.parentNode = element; + node.parentElement = element; + element.children.splice(index, 0, node); + if (global.document && typeof global.document._registerTree === 'function') { + global.document._registerTree(node); + } + return node; + }, + insertAdjacentHTML(position, html) { + if (position !== 'beforeend') { + return; + } + const idMatch = html.match(/id=\"([^\"]+)\"/); + const id = idMatch ? idMatch[1] : ''; + const checkbox = makeElement({ id, tagName: 'INPUT', classNames: ['d-none'] }); + checkbox.checked = true; + checkbox.name = html.match(/name=\"([^\"]+)\"/)?.[1] || ''; + element.appendChild(checkbox); + }, + getElementsByClassName(className) { + const result = []; + for (const child of element.children) { + if (child.classList.contains(className)) { + result.push(child); + } + result.push(...child.getElementsByClassName(className)); + } + return result; + }, + querySelector(selector) { + if (selector === '[type=submit]') { + for (const child of element.children) { + if (child.type === 'submit') { + return child; + } + const nested = child.querySelector(selector); + if (nested) { + return nested; + } + } + } + return null; + }, + }; + return element; +} + +function cloneTree(node) { + const copy = makeElement({ + id: node.id, + tagName: node.tagName, + innerHTML: node.innerHTML, + classNames: [], + value: node.value, + }); + for (const className of ['formset-form-remove', 'formset-form-restore', 'border', 'd-none']) { + if (node.classList.contains(className)) { + copy.classList.add(className); + } + } + for (const child of node.children) { + copy.appendChild(cloneTree(child)); + } + return copy; +} + +function makeDocument(elements = []) { + const elementsById = new Map(); + const domListeners = {}; + + const registerTree = (element) => { + if (element.id) { + elementsById.set(element.id, element); + } + for (const child of element.children) { + registerTree(child); + } + }; + + for (const element of elements) { + registerTree(element); + } + + return { + _registerTree: registerTree, + addEventListener(eventName, callback) { + domListeners[eventName] = callback; + }, + dispatch(eventName) { + const callback = domListeners[eventName]; + if (callback) { + callback(); + } + }, + getElementById(id) { + return elementsById.get(id) || null; + }, + getElementsByClassName(className) { + const result = []; + for (const element of elementsById.values()) { + if (element.classList.contains(className)) { + result.push(element); + } + } + return result; + }, + createElement(tagName) { + return makeElement({ tagName: String(tagName).toUpperCase() }); + }, + }; +} + +function loadFormsetScript() { + // eslint-disable-next-line no-new-func + new Function(scriptContent)(); +} + +function setupAddFormEnvironment(maxForms = '1') { + const root = makeElement({ id: 'root' }); + const form = makeElement({ id: 'formset_form' }); + const insert = makeElement({ id: 'formset_insert' }); + const addLink = makeElement({ id: 'formset_add', tagName: 'A' }); + const total = makeElement({ id: 'id_form-TOTAL_FORMS', tagName: 'INPUT', value: '0' }); + const max = makeElement({ id: 'id_form-MAX_NUM_FORMS', tagName: 'INPUT', value: maxForms }); + const emptyAlert = makeElement({ id: 'empty_alert', classNames: [] }); + + root.appendChild(form); + form.appendChild(addLink); + form.appendChild(total); + form.appendChild(max); + form.appendChild(insert); + form.appendChild(emptyAlert); + + const template = makeElement({ id: 'formset_template', tagName: 'TEMPLATE' }); + const childContainer = makeElement({ id: 'id_form-__prefix___container', classNames: ['border'] }); + const childRemove = makeElement({ id: 'id_form-__prefix___remove', classNames: ['formset-form-remove'] }); + const childRestore = makeElement({ + id: 'id_form-__prefix___restore', + classNames: ['formset-form-restore', 'd-none'], + }); + + template.content = { + cloneNode() { + return { + isFragment: true, + children: [cloneTree(childContainer), cloneTree(childRemove), cloneTree(childRestore)], + }; + }, + }; + + const document = makeDocument([root, form, addLink, insert, total, max, emptyAlert, template]); + global.document = document; + + return { document, insert, total, emptyAlert }; +} + +function setupRemoveRestoreEnvironment() { + const form = makeElement({ id: 'id_form-0_container', classNames: ['border'] }); + const innerBorder = makeElement({ id: 'id_form-0_inner', classNames: ['border'] }); + const remove = makeElement({ id: 'id_form-0_remove', classNames: ['formset-form-remove'] }); + const restore = makeElement({ id: 'id_form-0_restore', classNames: ['formset-form-restore', 'd-none'] }); + + form.appendChild(innerBorder); + form.appendChild(remove); + form.appendChild(restore); + + const total = makeElement({ id: 'id_form-TOTAL_FORMS', tagName: 'INPUT', value: '1' }); + const max = makeElement({ id: 'id_form-MAX_NUM_FORMS', tagName: 'INPUT', value: '10' }); + const template = makeElement({ id: 'formset_template', tagName: 'TEMPLATE' }); + template.content = { cloneNode: () => ({ isFragment: true, children: [] }) }; + + const holder = makeElement({ id: 'holder' }); + holder.appendChild(total); + holder.appendChild(max); + holder.appendChild(form); + + const document = makeDocument([holder, total, max, form, innerBorder, remove, restore, template]); + global.document = document; + + return { document, form, innerBorder, remove, restore }; +} + +function teardown() { + delete global.document; +} + +test('adds new form, updates total, and hides add button at max', () => { + const env = setupAddFormEnvironment('1'); + loadFormsetScript(); + env.document.dispatch('DOMContentLoaded'); + + const addLink = env.document.getElementById('formset_add'); + assert.ok(addLink); + + addLink.click(); + + assert.equal(env.emptyAlert.classList.contains('d-none'), true); + assert.equal(String(env.total.value), '1'); + assert.equal(addLink.classList.contains('d-none'), true); + assert.ok(env.document.getElementById('id_form-0_container')); + assert.ok(env.document.getElementById('id_form-0_remove')); + assert.ok(env.document.getElementById('id_form-0_restore')); + + teardown(); +}); + +test('remove and restore toggles delete checkbox and visual state', () => { + const env = setupRemoveRestoreEnvironment(); + loadFormsetScript(); + env.document.dispatch('DOMContentLoaded'); + + env.remove.click(); + + const deleteElement = env.document.getElementById('id_form-0-DELETE'); + assert.ok(deleteElement); + assert.equal(deleteElement.checked, true); + assert.equal(env.remove.classList.contains('d-none'), true); + assert.equal(env.restore.classList.contains('d-none'), false); + assert.equal(env.form.classList.contains('border-danger'), true); + assert.equal(env.innerBorder.classList.contains('border-danger'), true); + assert.equal(env.form.style.opacity, '.5'); + + env.restore.click(); + + assert.equal(deleteElement.checked, false); + assert.equal(env.remove.classList.contains('d-none'), false); + assert.equal(env.restore.classList.contains('d-none'), true); + assert.equal(env.form.classList.contains('border-danger'), false); + assert.equal(env.innerBorder.classList.contains('border-danger'), false); + assert.equal(env.form.style.opacity, '1'); + + teardown(); +}); diff --git a/src/shiftings/static/js/tests/modal-handlers.test.cjs b/src/shiftings/static/js/tests/modal-handlers.test.cjs new file mode 100644 index 0000000..98033b2 --- /dev/null +++ b/src/shiftings/static/js/tests/modal-handlers.test.cjs @@ -0,0 +1,111 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); + +const { + MockCustomEvent, + createDocument, + createElement, + installDomGlobals, + loadScript, + teardownDomGlobals, +} = require('./dom-harness.cjs'); + +function setupRemoveModalEnvironment() { + const root = createElement({ tagName: 'HTML', id: 'root' }); + const modal = createElement({ id: 'confirmRemoveModal' }); + const placeholder1 = createElement({ id: 'remove_object_placeholder_1' }); + const placeholder2 = createElement({ id: 'remove_object_placeholder_2' }); + const successUrl = createElement({ id: 'id_success_url', tagName: 'INPUT', value: '' }); + const form = createElement({ id: 'id_remove_form' }); + root.appendChild(modal); + root.appendChild(placeholder1); + root.appendChild(placeholder2); + root.appendChild(successUrl); + root.appendChild(form); + + const document = createDocument({ root }); + installDomGlobals(document, { CustomEvent: MockCustomEvent }); + return { document, modal, placeholder1, placeholder2, successUrl, form }; +} + +function setupAutofocusEnvironment() { + const root = createElement({ tagName: 'HTML', id: 'root' }); + const modal = createElement({ id: 'modal' }); + const autofocusField = createElement({ id: 'focus-me', tagName: 'INPUT' }); + autofocusField.setAttribute('autofocus', 'autofocus'); + modal.appendChild(autofocusField); + root.appendChild(modal); + + const document = createDocument({ root }); + installDomGlobals(document, { CustomEvent: MockCustomEvent }); + return { document, modal, autofocusField }; +} + +function setupSelectShiftOrgEnvironment() { + const root = createElement({ tagName: 'HTML', id: 'root' }); + const modal = createElement({ id: 'selectShiftOrgModal' }); + const actionDate = createElement({ id: 'id_action_date', tagName: 'INPUT', value: '' }); + root.appendChild(modal); + root.appendChild(actionDate); + + const document = createDocument({ root }); + installDomGlobals(document, { CustomEvent: MockCustomEvent }); + return { document, modal, actionDate }; +} + +test('remove modal fills placeholders from triggering button', () => { + const env = setupRemoveModalEnvironment(); + loadScript(path.resolve(__dirname, '..', 'remove_modal.js')); + env.document.dispatchEvent('DOMContentLoaded'); + + const button = { + getAttribute(attributeName) { + return { + 'data-bs-remove-url': '/delete/1/', + 'data-bs-remove-name': 'Test item', + 'data-bs-success-url': '/success/', + }[attributeName]; + }, + }; + + env.modal.dispatchEvent(new MockCustomEvent('show.bs.modal', { relatedTarget: button })); + + assert.equal(env.form.action, '/delete/1/'); + assert.equal(env.placeholder1.textContent, 'Test item'); + assert.equal(env.placeholder2.textContent, 'Test item'); + assert.equal(env.successUrl.value, '/success/'); + + teardownDomGlobals(); +}); + +test('modal autofocus focuses first autofocus field', () => { + const env = setupAutofocusEnvironment(); + loadScript(path.resolve(__dirname, '..', '..', '..', 'shifts', 'static', 'js', 'modal_autofocus.js')); + + env.document.dispatchEvent(new MockCustomEvent('shown.bs.modal', { target: env.modal })); + + assert.equal(env.autofocusField.focusCalled, true); + + teardownDomGlobals(); +}); + +test('calendar shift modal copies date into action field', () => { + const env = setupSelectShiftOrgEnvironment(); + loadScript(path.resolve(__dirname, '..', '..', '..', 'cal', 'static', 'js', 'set_cal_shift_create_date.js')); + env.document.dispatchEvent('DOMContentLoaded'); + + const button = { + getAttribute(attributeName) { + return { + 'data-bs-date': '2026-04-17', + }[attributeName]; + }, + }; + + env.modal.dispatchEvent(new MockCustomEvent('show.bs.modal', { relatedTarget: button })); + + assert.equal(env.actionDate.value, '2026-04-17'); + + teardownDomGlobals(); +}); diff --git a/src/shiftings/static/js/tests/navigation-and-sliders.test.cjs b/src/shiftings/static/js/tests/navigation-and-sliders.test.cjs new file mode 100644 index 0000000..a069fec --- /dev/null +++ b/src/shiftings/static/js/tests/navigation-and-sliders.test.cjs @@ -0,0 +1,199 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); + +const { + MockCustomEvent, + MockMutationObserver, + createDocument, + createElement, + installDomGlobals, + loadScript, + teardownDomGlobals, +} = require('./dom-harness.cjs'); + +function makeTimeSliderGroup({ sliderId = 'slider-1', name = 'start_delay_1', value = '20', start = '0:50', startDay = '0', min = '0', max = '90' } = {}) { + const outer = createElement({ id: `${sliderId}-outer` }); + const middle = createElement({ id: `${sliderId}-middle` }); + const slider = createElement({ id: sliderId, tagName: 'INPUT', classNames: ['time-slider'], value, name }); + slider.attributes = { + start: { nodeValue: start }, + startDay: { nodeValue: startDay }, + }; + slider.min = min; + slider.max = max; + + const display = createElement({ id: `${sliderId}-display`, tagName: 'INPUT', classNames: ['time-slider-display'], value: '' }); + const dayContainer = createElement({ id: `${sliderId}-day-container`, classNames: ['time-slider-day-container'] }); + const day = createElement({ id: `${sliderId}-day`, classNames: ['time-slider-day'], innerText: '' }); + + middle.appendChild(slider); + middle.appendChild(display); + middle.appendChild(dayContainer); + middle.appendChild(day); + outer.appendChild(middle); + + return { outer, middle, slider, display, dayContainer, day }; +} + +function setupTimeSliderEnvironment(groups) { + const root = createElement({ tagName: 'HTML', id: 'root' }); + for (const group of groups) { + root.appendChild(group.outer); + } + const document = createDocument({ root }); + installDomGlobals(document, { MutationObserver: MockMutationObserver, CustomEvent: MockCustomEvent }); + return { document, root }; +} + +function setupLinkSlidersEnvironment() { + const root = createElement({ tagName: 'HTML', id: 'root' }); + const group = makeTimeSliderGroup({ sliderId: 'start-slider', name: 'start_delay_1', value: '30', start: '1:15', startDay: '2' }); + group.display.value = '1:15'; + group.day.innerText = '2'; + const duration = createElement({ id: 'duration-1', tagName: 'INPUT', name: 'duration_1' }); + duration.attributes = { + start: { nodeValue: '' }, + startDay: { nodeValue: '' }, + }; + const eventSpy = { calls: 0, lastEvent: null }; + duration.addEventListener('startChanged', (event) => { + eventSpy.calls += 1; + eventSpy.lastEvent = event; + }); + + root.appendChild(group.outer); + root.appendChild(duration); + const document = createDocument({ root }); + installDomGlobals(document, { MutationObserver: MockMutationObserver, CustomEvent: MockCustomEvent }); + return { document, group, duration, eventSpy }; +} + +function setupTimeFrameVisibilityEnvironment(initialValue = '1') { + const root = createElement({ tagName: 'HTML', id: 'root' }); + const timeFrame = createElement({ id: 'id_time_frame_field', tagName: 'SELECT', value: initialValue }); + const monthWrapper = createElement({ id: 'month-wrapper' }); + const month = createElement({ id: 'id_month_field', tagName: 'INPUT', value: '4' }); + monthWrapper.appendChild(month); + const weekdayWrapper = createElement({ id: 'weekday-wrapper' }); + const weekday = createElement({ id: 'id_week_day_field', tagName: 'INPUT', value: '2' }); + weekdayWrapper.appendChild(weekday); + root.appendChild(timeFrame); + root.appendChild(monthWrapper); + root.appendChild(weekdayWrapper); + + const calls = { save: [], restore: [] }; + const saveAndRestore = { + save(...args) { + calls.save.push(args); + }, + restore(...args) { + calls.restore.push(args); + }, + }; + + const document = createDocument({ root }); + installDomGlobals(document, { MutationObserver: MockMutationObserver, CustomEvent: MockCustomEvent, saveAndRestore }); + return { document, timeFrame, month, monthWrapper, weekday, weekdayWrapper, calls }; +} + +test('chickens easter egg redirects after key sequence', () => { + const root = createElement({ tagName: 'HTML', id: 'root' }); + const document = createDocument({ root, locationHref: '/start/' }); + installDomGlobals(document, { MutationObserver: MockMutationObserver, CustomEvent: MockCustomEvent }); + loadScript(path.resolve(__dirname, '..', 'chickens.js')); + + for (const key of ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']) { + document.dispatchEvent({ type: 'keyup', key }); + } + + assert.equal(document.location.href, 'https://www.youtube.com/watch?v=AVlzryCQg8s'); + + teardownDomGlobals(); +}); + +test('time slider updates display and day from slider input', () => { + const group = makeTimeSliderGroup(); + const env = setupTimeSliderEnvironment([group]); + loadScript(path.resolve(__dirname, '..', 'time_slider.js')); + env.document.dispatchEvent('DOMContentLoaded'); + + assert.equal(group.display.value, '1:10'); + assert.equal(group.dayContainer.classList.contains('d-none'), true); + assert.equal(group.day.innerText, '0'); + + group.slider.value = '90'; + env.document.dispatchEvent({ type: 'input', target: group.slider }); + + assert.equal(group.display.value, '2:20'); + assert.equal(group.dayContainer.classList.contains('d-none'), true); + assert.equal(group.day.innerText, '0'); + + group.display.value = '3:30'; + env.document.dispatchEvent({ type: 'input', target: group.display }); + + assert.equal(group.slider.value, '90'); + assert.equal(group.display.value, '2:20'); + + teardownDomGlobals(); +}); + +test('time slider initializes newly observed nodes', () => { + const group = makeTimeSliderGroup({ sliderId: 'slider-1' }); + const env = setupTimeSliderEnvironment([group]); + loadScript(path.resolve(__dirname, '..', 'time_slider.js')); + env.document.dispatchEvent('DOMContentLoaded'); + + const newGroup = makeTimeSliderGroup({ sliderId: 'slider-2', name: 'start_delay_2', value: '15', start: '2:00', startDay: '1' }); + env.root.appendChild(newGroup.outer); + const observer = MockMutationObserver.instances.at(-1); + observer.trigger(); + + assert.equal(newGroup.display.value, '2:15'); + assert.equal(newGroup.day.innerText, '1'); + + teardownDomGlobals(); +}); + +test('link sliders copies start values into duration target and emits startChanged', () => { + const env = setupLinkSlidersEnvironment(); + loadScript(path.resolve(__dirname, '..', '..', '..', 'shifts', 'static', 'js', 'link_sliders.js')); + env.document.dispatchEvent('DOMContentLoaded'); + + assert.equal(env.duration.attributes.start.nodeValue, '1:15'); + assert.equal(env.duration.attributes.startDay.nodeValue, '2'); + assert.equal(env.eventSpy.calls, 1); + assert.equal(env.eventSpy.lastEvent.target, env.duration); + + env.group.display.value = '2:05'; + env.group.day.innerText = '3'; + env.group.slider.value = '75'; + env.document.dispatchEvent({ type: 'input', target: env.group.slider }); + + assert.equal(env.duration.attributes.start.nodeValue, '2:05'); + assert.equal(env.duration.attributes.startDay.nodeValue, '3'); + assert.equal(env.eventSpy.calls, 2); + + teardownDomGlobals(); +}); + +test('time frame visibility toggles month and weekday fields', () => { + const env = setupTimeFrameVisibilityEnvironment('1'); + loadScript(path.resolve(__dirname, '..', '..', '..', 'shifts', 'static', 'js', 'time_frame_visibility.js')); + env.document.dispatchEvent('DOMContentLoaded'); + + assert.equal(env.monthWrapper.classList.contains('d-none'), true); + assert.equal(env.weekdayWrapper.classList.contains('d-none'), false); + assert.equal(env.calls.save.length, 1); + assert.equal(env.calls.restore.length, 1); + + env.timeFrame.value = '5'; + env.timeFrame.dispatchEvent({ type: 'change', target: env.timeFrame }); + + assert.equal(env.monthWrapper.classList.contains('d-none'), false); + assert.equal(env.weekdayWrapper.classList.contains('d-none'), true); + assert.equal(env.calls.save.length, 2); + assert.equal(env.calls.restore.length, 2); + + teardownDomGlobals(); +}); diff --git a/src/shiftings/static/js/tests/save_and_restore.test.cjs b/src/shiftings/static/js/tests/save_and_restore.test.cjs new file mode 100644 index 0000000..2bba434 --- /dev/null +++ b/src/shiftings/static/js/tests/save_and_restore.test.cjs @@ -0,0 +1,82 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const { createElement } = require('./dom-harness.cjs'); + +const scriptPath = path.resolve(__dirname, '..', 'save_and_restore.js'); +const scriptContent = fs.readFileSync(scriptPath, 'utf8'); + +function loadSaveAndRestore() { + // eslint-disable-next-line no-new-func + return new Function(`${scriptContent}\nreturn saveAndRestore;`)(); +} + +function makeComponent(tagName, id, value) { + const wrapper = createElement({ id: `${id}-wrapper` }); + const component = createElement({ tagName, id, value, innerText: value }); + if (tagName === 'SELECT') { + component.selectedIndex = value; + } + wrapper.appendChild(component); + return component; +} + +test('save hides and clears components, restore shows and restores values', () => { + const saveAndRestore = loadSaveAndRestore(); + + const input = makeComponent('INPUT', 'input-id', 'abc'); + const select = makeComponent('SELECT', 'select-id', 2); + const text = makeComponent('SPAN', 'text-id', 'hello'); + + saveAndRestore.save(true, true, input, select, text); + + assert.equal(input.parentNode.classList.contains('d-none'), true); + assert.equal(select.parentNode.classList.contains('d-none'), true); + assert.equal(text.parentNode.classList.contains('d-none'), true); + + assert.equal(input.value, ''); + assert.equal(select.selectedIndex, 0); + assert.equal(text.innerText, ''); + + saveAndRestore.restore(true, input, select, text); + + assert.equal(input.parentNode.classList.contains('d-none'), false); + assert.equal(select.parentNode.classList.contains('d-none'), false); + assert.equal(text.parentNode.classList.contains('d-none'), false); + + assert.equal(input.value, 'abc'); + assert.equal(select.selectedIndex, 2); + assert.equal(text.innerText, 'hello'); +}); + +test('save keeps previously stored non-empty value when later value is empty', () => { + const saveAndRestore = loadSaveAndRestore(); + + const input = makeComponent('INPUT', 'persist-id', 'first'); + saveAndRestore.save(false, false, input); + + input.value = ''; + saveAndRestore.save(false, false, input); + + input.value = 'changed'; + saveAndRestore.restore(false, input); + + assert.equal(input.value, 'first'); +}); + +test('restore only applies once for stored values', () => { + const saveAndRestore = loadSaveAndRestore(); + + const input = makeComponent('INPUT', 'once-id', 'value-1'); + saveAndRestore.save(false, false, input); + + input.value = 'value-2'; + saveAndRestore.restore(false, input); + assert.equal(input.value, 'value-1'); + + input.value = 'value-3'; + saveAndRestore.restore(false, input); + assert.equal(input.value, 'value-3'); +}); diff --git a/src/shiftings/static/js/tests/theme-toggle.test.cjs b/src/shiftings/static/js/tests/theme-toggle.test.cjs new file mode 100644 index 0000000..fe2ab9f --- /dev/null +++ b/src/shiftings/static/js/tests/theme-toggle.test.cjs @@ -0,0 +1,175 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const test = require('node:test'); + +const { + createDocument, + createElement, + installDomGlobals, + loadScript, + teardownDomGlobals, +} = require('./dom-harness.cjs'); + +function setupEnvironment({ userTheme = 'auto', storageTheme = null, xMatch = false, lightMatch = false } = {}) { + const root = createElement({ tagName: 'HTML', id: 'root', dataset: { userTheme } }); + const lightOption = createElement({ id: 'light-option', tagName: 'A', classNames: ['js-theme-option'], dataset: { theme: 'light', themeForm: 'theme-light-form' } }); + const darkOption = createElement({ id: 'dark-option', tagName: 'A', classNames: ['js-theme-option'], dataset: { theme: 'dark', themeForm: 'theme-dark-form' } }); + const autoOption = createElement({ id: 'auto-option', tagName: 'A', classNames: ['js-theme-option'], dataset: { theme: 'auto', themeForm: 'theme-auto-form' } }); + const lightForm = createElement({ id: 'theme-light-form', tagName: 'FORM' }); + const darkForm = createElement({ id: 'theme-dark-form', tagName: 'FORM' }); + const autoForm = createElement({ id: 'theme-auto-form', tagName: 'FORM' }); + + lightForm.submit = function submit() { + this.submitted = true; + }; + darkForm.submit = function submit() { + this.submitted = true; + }; + autoForm.submit = function submit() { + this.submitted = true; + }; + + root.appendChild(lightOption); + root.appendChild(darkOption); + root.appendChild(autoOption); + root.appendChild(lightForm); + root.appendChild(darkForm); + root.appendChild(autoForm); + + const document = createDocument({ root }); + const storage = new Map(); + if (storageTheme !== null) { + storage.set('shiftings-theme-preference', storageTheme); + } + + const queryMatch = new Map([ + ['(prefers-color-scheme: x)', xMatch], + ['(prefers-color-scheme: light)', lightMatch], + ]); + const listenersByQuery = new Map(); + + installDomGlobals(document, { + window: { + matchMedia(query) { + const listeners = []; + listenersByQuery.set(query, listeners); + return { + get matches() { + return Boolean(queryMatch.get(query)); + }, + addEventListener(eventName, callback) { + if (eventName === 'change') { + listeners.push(callback); + } + }, + }; + }, + }, + }); + global.localStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, value); + }, + }; + + return { + document, + root, + lightOption, + darkOption, + autoOption, + lightForm, + darkForm, + autoForm, + storage, + queryMatch, + listenersByQuery, + }; +} + +function teardownEnvironment() { + teardownDomGlobals(); +} + +function runThemeToggleScript() { + loadScript(path.resolve(__dirname, '..', 'theme-toggle.js')); +} + +test('prefers localStorage theme over account theme', () => { + const env = setupEnvironment({ userTheme: 'dark', storageTheme: 'light' }); + runThemeToggleScript(); + + assert.equal(env.root.dataset.themeMode, 'light'); + assert.equal(env.root.dataset.bsTheme, 'light'); + assert.equal(env.lightOption.classList.contains('active'), true); + assert.equal(env.darkOption.classList.contains('active'), false); + assert.equal(env.autoOption.classList.contains('active'), false); + + teardownEnvironment(); +}); + +test('falls back to account theme when no localStorage value exists', () => { + const env = setupEnvironment({ userTheme: 'dark', storageTheme: null }); + runThemeToggleScript(); + + assert.equal(env.root.dataset.themeMode, 'dark'); + assert.equal(env.root.dataset.bsTheme, 'dark'); + + teardownEnvironment(); +}); + +test('clicking option saves preference, updates active entry, and submits linked form', () => { + const env = setupEnvironment({ userTheme: 'auto', storageTheme: null, xMatch: true }); + runThemeToggleScript(); + + const autoClickEvent = { + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + type: 'click', + }; + env.autoOption.dispatchEvent(autoClickEvent); + + assert.equal(autoClickEvent.defaultPrevented, true); + assert.equal(env.storage.get('shiftings-theme-preference'), 'auto'); + assert.equal(env.root.dataset.themeMode, 'auto'); + assert.equal(env.root.dataset.bsTheme, 'light'); + assert.equal(env.autoForm.submitted, true); + assert.equal(env.autoOption.classList.contains('active'), true); + assert.equal(env.lightOption.classList.contains('active'), false); + assert.equal(env.darkOption.classList.contains('active'), false); + + teardownEnvironment(); +}); + +test('system theme change re-applies theme only in auto mode', () => { + const env = setupEnvironment({ userTheme: 'auto', storageTheme: 'auto', xMatch: false, lightMatch: false }); + runThemeToggleScript(); + + const systemListeners = env.listenersByQuery.get('(prefers-color-scheme: light)'); + assert.ok(systemListeners); + assert.equal(systemListeners.length, 1); + + env.queryMatch.set('(prefers-color-scheme: x)', true); + env.queryMatch.set('(prefers-color-scheme: light)', true); + systemListeners[0](); + + assert.equal(env.root.dataset.themeMode, 'auto'); + assert.equal(env.root.dataset.bsTheme, 'light'); + + env.darkOption.dispatchEvent({ type: 'click', preventDefault() {} }); + assert.equal(env.root.dataset.themeMode, 'dark'); + + env.queryMatch.set('(prefers-color-scheme: x)', false); + env.queryMatch.set('(prefers-color-scheme: light)', false); + systemListeners[0](); + + assert.equal(env.root.dataset.themeMode, 'dark'); + assert.equal(env.root.dataset.bsTheme, 'dark'); + + teardownEnvironment(); +}); diff --git a/src/shiftings/static/js/theme-toggle.js b/src/shiftings/static/js/theme-toggle.js new file mode 100644 index 0000000..cccc512 --- /dev/null +++ b/src/shiftings/static/js/theme-toggle.js @@ -0,0 +1,68 @@ +(() => { + const STORAGE_KEY = 'shiftings-theme-preference'; + const root = document.documentElement; + + const resolveTheme = (mode) => { + if (mode === 'auto') { + if (!window.matchMedia) { + return 'dark'; + } + return window.matchMedia('(prefers-color-scheme: x)').matches ? 'light' : 'dark'; + } + return mode; + }; + + const applyTheme = (mode) => { + root.dataset.themeMode = mode; + root.dataset.bsTheme = resolveTheme(mode); + }; + + const saveTheme = (mode) => { + localStorage.setItem(STORAGE_KEY, mode); + applyTheme(mode); + }; + + const updateActiveEntry = (mode) => { + const entries = document.querySelectorAll('.js-theme-option'); + entries.forEach((entry) => { + entry.classList.toggle('active', entry.dataset.theme === mode); + }); + }; + + const storageTheme = localStorage.getItem(STORAGE_KEY); + const hasStorageTheme = storageTheme !== null; + const accountTheme = root.dataset.userTheme || 'auto'; + const preferredTheme = hasStorageTheme ? storageTheme : accountTheme; + + applyTheme(preferredTheme); + updateActiveEntry(preferredTheme); + + const systemTheme = window.matchMedia('(prefers-color-scheme: light)'); + systemTheme.addEventListener('change', () => { + if ((root.dataset.themeMode || 'auto') === 'auto') { + applyTheme('auto'); + updateActiveEntry('auto'); + } + }); + + const options = document.querySelectorAll('.js-theme-option'); + options.forEach((option) => { + option.addEventListener('click', (event) => { + event.preventDefault(); + const mode = option.dataset.theme; + if (!mode) { + return; + } + saveTheme(mode); + updateActiveEntry(mode); + const formId = option.dataset.themeForm; + if (!formId) { + return; + } + const form = document.getElementById(formId); + if (form) { + form.submit(); + } + }); + }); +})(); diff --git a/src/shiftings/templates/base.html b/src/shiftings/templates/base.html index 4675027..44f7981 100644 --- a/src/shiftings/templates/base.html +++ b/src/shiftings/templates/base.html @@ -1,10 +1,29 @@ - + {# #} + {% bootstrap_css %} @@ -60,10 +79,12 @@ {% endif %} {% block content %}{% endblock %} +{% block modals %}{% endblock %}
© bjökgda🐔, 2022-{{ today.year }}
{% bootstrap_javascript %} + - \ No newline at end of file + diff --git a/src/shiftings/templates/generic/fluid_form_base.html b/src/shiftings/templates/generic/fluid_form_base.html index 6ed6b78..3a46858 100644 --- a/src/shiftings/templates/generic/fluid_form_base.html +++ b/src/shiftings/templates/generic/fluid_form_base.html @@ -1,12 +1,3 @@ -{% extends "base.html" %} -{% block breadcrumbs %} - {% include "template/breadcrumbs.html" %} -{% endblock %} -{% block content %} -
-
- {% block form %} - {% endblock %} -
-
-{% endblock %} \ No newline at end of file +{% extends "generic/form_base.html" %} +{% block form_container_class %}container-fluid{% endblock %} +{% block form_content_class %}col-lg-12 col-xl-8{% endblock %} diff --git a/src/shiftings/templates/generic/form_base.html b/src/shiftings/templates/generic/form_base.html index 63fd9f1..d5286a2 100644 --- a/src/shiftings/templates/generic/form_base.html +++ b/src/shiftings/templates/generic/form_base.html @@ -3,10 +3,10 @@ {% include "template/breadcrumbs.html" %} {% endblock %} {% block content %} -
-
+
+
{% block form %} {% endblock %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/shiftings/templates/language.html b/src/shiftings/templates/language.html index bbacdb1..6e61a17 100644 --- a/src/shiftings/templates/language.html +++ b/src/shiftings/templates/language.html @@ -16,7 +16,7 @@ {% trans "Language" %} - \ No newline at end of file +
diff --git a/src/shiftings/templates/switch_user.html b/src/shiftings/templates/switch_user.html index ccebc2e..c254b21 100644 --- a/src/shiftings/templates/switch_user.html +++ b/src/shiftings/templates/switch_user.html @@ -5,7 +5,7 @@ {% trans "Switch User" %} - -{% endif %} \ No newline at end of file +{% endif %} diff --git a/src/shiftings/templates/template/formset_remove_restore_buttons.html b/src/shiftings/templates/template/formset_remove_restore_buttons.html new file mode 100644 index 0000000..70c1325 --- /dev/null +++ b/src/shiftings/templates/template/formset_remove_restore_buttons.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/shiftings/templates/template/icon_action_button.html b/src/shiftings/templates/template/icon_action_button.html new file mode 100644 index 0000000..0ba2068 --- /dev/null +++ b/src/shiftings/templates/template/icon_action_button.html @@ -0,0 +1,3 @@ + + {% if label %} {{ label }}{% endif %} + \ No newline at end of file diff --git a/src/shiftings/templates/template/modal_base.html b/src/shiftings/templates/template/modal_base.html new file mode 100644 index 0000000..eb3f075 --- /dev/null +++ b/src/shiftings/templates/template/modal_base.html @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/src/shiftings/templates/template/remove_modal.html b/src/shiftings/templates/template/remove_modal.html index 064be70..73a503b 100644 --- a/src/shiftings/templates/template/remove_modal.html +++ b/src/shiftings/templates/template/remove_modal.html @@ -1,44 +1,42 @@ - - -