FossilRepo

Initial import: Fossilrepo — self-hosted Fossil forge with Django+HTMX

ragelink 2026-04-07 01:15 trunk
Commit 4ce269c36e324a17f12050661d87540c2bea18d45011d166ef7919c966899b24
206 files changed +3 +21 +36 +32 +44 +21 +62 +13 +29 +57 +89 +1 +104 +66 +77 +39 +86 +7 +22 +46 +10 +29 +57 +364 +9 +180 +37 +6 +18 +23 +6 +18 +19 +74 +66 +13 +149 +7 +3 +1 +94 +23 +81 +32 +18 +29 +65 +25 +9 +83 +363 +42 +3 +976 +42 +117 +164 +8 +1282 +12 +6 +18 +168 +22 +15 +133 +13 +86 +14 +26 +6 +55 +330 +178 +38 +179 +33 +176 +11 +6 +16 +180 +15 +53 +13 +40 +29 +6 +35 +395 +39 +91 +16 +88 +71 +78 +19 +769 +110 +4 +13 +19 +15 +15 +44 +36 +6 +99 +12 +37 +17 +14 +95 +12 +2 +115 +39 +17 +62 +37 +24 +19 +1 +16 +3 +76 +28 +16 +13 +12 +37 +28 +36 +25 +111 +24 +6 +41 +30 +133 +28 +70 +42 +29 +44 +39 +28 +30 +18 +21 +26 +32 +39 +28 +62 +39 +28 +39 +28 +28 +38 +40 +28 +37 +10 +1 +28 +8 +33 +1 +39 +28 +39 +6 +138 +1341
+ AGENTS.md + CLAUDE.md + CODE_OF_CONDUCT.md + CONTRIBUTING.md + Dockerfile + LICENSE + Makefile + README.md + SECURITY.md + _old_CLAUDE.md + _old_bootstrap.md + _old_fossilrepo/__init__.py + _old_fossilrepo/cli/__init__.py + _old_fossilrepo/cli/main.py + _old_fossilrepo/server/__init__.py + _old_fossilrepo/server/config.py + _old_fossilrepo/server/manager.py + _old_fossilrepo/sync/__init__.py + _old_fossilrepo/sync/mappings.py + _old_fossilrepo/sync/mirror.py + auth1/__init__.py + auth1/apps.py + auth1/forms.py + auth1/migrations/__init__.py + auth1/tests.py + auth1/urls.py + auth1/views.py + boilerworks.yaml + bootstrap.md + config/__init__.py + config/celery.py + config/settings.py + config/urls.py + config/wsgi.py + conftest.py + core/__init__.py + core/admin.py + core/apps.py + core/context_processors.py + core/management/__init__.py + core/management/commands/__init__.py + core/middleware/__init__.py + core/middleware/current_user.py + core/migrations/__init__.py + core/models.py + core/permissions.py + core/templatetags/__init__.py + core/templatetags/permissions_tags.py + core/tests.py + core/urls.py + core/views.py + ctl/__init__.py + ctl/main.py + docker-compose.yaml + docker/Caddyfile + docker/Dockerfile.fossil + docker/docker-compose.fossil.yml + docker/litestream.yml + fossil-platform/Dockerfile + fossil-platform/README.md + fossil/__init__.py + fossil/admin.py + fossil/apps.py + fossil/cli.py + fossil/migrations/0001_initial.py + fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py + fossil/migrations/__init__.py + fossil/models.py + fossil/reader.py + fossil/signals.py + fossil/tasks.py + fossil/tests.py + fossil/urls.py + fossil/views.py + items/__init__.py + items/admin.py + items/apps.py + items/forms.py + items/migrations/0001_initial.py + items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py + items/migrations/__init__.py + items/models.py + items/tests.py + items/urls.py + items/views.py + manage.py + organization/__init__.py + organization/admin.py + organization/apps.py + organization/forms.py + organization/migrations/0001_initial.py + organization/migrations/0002_historicalteam_team.py + organization/migrations/__init__.py + organization/models.py + organization/tests.py + organization/urls.py + organization/views.py + pages/__init__.py + pages/admin.py + pages/apps.py + pages/forms.py + pages/migrations/0001_initial.py + pages/migrations/__init__.py + pages/models.py + pages/tests.py + pages/urls.py + pages/views.py + projects/__init__.py + projects/admin.py + projects/apps.py + projects/forms.py + projects/migrations/0001_initial.py + projects/migrations/__init__.py + projects/models.py + projects/tests.py + projects/urls.py + projects/views.py + pyproject.toml + run.sh + startup.py + static/admin/css/dark_theme.css + static/admin/img/logo-dark.png + static/admin/img/logo-dark.svg + static/css/input.css + static/img/fossilrepo-logo-dark.png + static/img/fossilrepo-logo-dark.svg + templates/403.html + templates/404.html + templates/500.html + templates/admin/base_site.html + templates/auth1/login.html + templates/base.html + templates/dashboard.html + templates/fossil/_copy_hash.html + templates/fossil/_keyboard_help.html + templates/fossil/_project_nav.html + templates/fossil/branch_list.html + templates/fossil/checkin_detail.html + templates/fossil/code_blame.html + templates/fossil/code_browser.html + templates/fossil/code_file.html + templates/fossil/compare.html + templates/fossil/doc_page.html + templates/fossil/docs_index.html + templates/fossil/file_history.html + templates/fossil/forum_list.html + templates/fossil/forum_thread.html + templates/fossil/partials/file_tree.html + templates/fossil/partials/ticket_table.html + templates/fossil/partials/timeline_entries.html + templates/fossil/repo_stats.html + templates/fossil/search.html + templates/fossil/sync.html + templates/fossil/tag_list.html + templates/fossil/technote_list.html + templates/fossil/ticket_detail.html + templates/fossil/ticket_form.html + templates/fossil/ticket_list.html + templates/fossil/timeline.html + templates/fossil/user_activity.html + templates/fossil/wiki_form.html + templates/fossil/wiki_list.html + templates/fossil/wiki_page.html + templates/includes/nav.html + templates/includes/sidebar.html + templates/items/item_confirm_delete.html + templates/items/item_detail.html + templates/items/item_form.html + templates/items/item_list.html + templates/items/partials/item_table.html + templates/organization/member_add.html + templates/organization/member_confirm_remove.html + templates/organization/member_list.html + templates/organization/partials/member_table.html + templates/organization/partials/team_member_table.html + templates/organization/partials/team_table.html + templates/organization/settings.html + templates/organization/settings_form.html + templates/organization/team_confirm_delete.html + templates/organization/team_detail.html + templates/organization/team_form.html + templates/organization/team_list.html + templates/organization/team_member_add.html + templates/organization/team_member_confirm_remove.html + templates/pages/page_confirm_delete.html + templates/pages/page_detail.html + templates/pages/page_form.html + templates/pages/page_list.html + templates/pages/partials/page_table.html + templates/projects/partials/project_table.html + templates/projects/partials/project_team_table.html + templates/projects/project_confirm_delete.html + templates/projects/project_detail.html + templates/projects/project_form.html + templates/projects/project_list.html + templates/projects/project_team_add.html + templates/projects/project_team_confirm_remove.html + templates/projects/project_team_edit.html + testdata/__init__.py + testdata/apps.py + testdata/management/__init__.py + testdata/management/commands/__init__.py + testdata/management/commands/seed.py + testdata/migrations/__init__.py + tests/__init__.py + uv.lock
+3
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -0,0 +1,3 @@
1
+# Agents -- Fossilrepo Django + HTMX
2
+
3
+Read [`bootstrap.md`](bootstrap.md) before writing any code. It is the primary conventions document.
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -0,0 +1,3 @@
 
 
 
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -0,0 +1,3 @@
1 # Agents -- Fossilrepo Django + HTMX
2
3 Read [`bootstrap.md`](bootstrap.md) before writing any code. It is the primary conventions document.
+21
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -0,0 +1,21 @@
1
+# Claude -- fossilrepo
2
+
3
+Primary conventions doc: [`bootstrap.md`](bootstrap.md)
4
+
5
+Read it before writing any code.
6
+
7
+## Project Overview
8
+
9
+fossilrepo is an omnibus-style installer for a self-hosted Fossil forge. Django+HTMX management layer wrapping Fossil SCM server infrastructure with Caddy (SSL/routing), Litestream (S3 backups), and a sync bridge to GitHub/GitLab. Open source (MIT).
10
+
11
+## Stack
12
+
13
+- **Backend**: Django 5 (Python 3.12+)
14
+- **Frontend**: HTMX 2.0 + Alpine.js 3 + Tailwind CSS (CDN)
15
+- **API**: Django views returning HTML (full pages + HTMX partials)
16
+- **ORM**: Django ORM with `Tracking` and `BaseCoreModel` base classes
17
+- **Auth**: Session-based (Django native, httpOnly cookies)
18
+- **Permissions**: Group-based via `P` enum (`core/permissions.py`)
19
+- **Jobs**: Celery + Redis
20
+- **Database**: PostgreSQL 16
21
+- **Linter**: Ruff (check + forma
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -0,0 +1,21 @@
1 # Claude -- fossilrepo
2
3 Primary conventions doc: [`bootstrap.md`](bootstrap.md)
4
5 Read it before writing any code.
6
7 ## Project Overview
8
9 fossilrepo is an omnibus-style installer for a self-hosted Fossil forge. Django+HTMX management layer wrapping Fossil SCM server infrastructure with Caddy (SSL/routing), Litestream (S3 backups), and a sync bridge to GitHub/GitLab. Open source (MIT).
10
11 ## Stack
12
13 - **Backend**: Django 5 (Python 3.12+)
14 - **Frontend**: HTMX 2.0 + Alpine.js 3 + Tailwind CSS (CDN)
15 - **API**: Django views returning HTML (full pages + HTMX partials)
16 - **ORM**: Django ORM with `Tracking` and `BaseCoreModel` base classes
17 - **Auth**: Session-based (Django native, httpOnly cookies)
18 - **Permissions**: Group-based via `P` enum (`core/permissions.py`)
19 - **Jobs**: Celery + Redis
20 - **Database**: PostgreSQL 16
21 - **Linter**: Ruff (check + forma
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,36 @@
1
+# Contributor Covenant Code of Conduct
2
+
3
+## Our Pledge
4
+
5
+We as members, contributors, and leaders pledge to make participation in our
6
+community a harassment-free experience for everyone, regardless of age, body
7
+size, visible or invisible disability, ethnicity, sex characteristics, gender
8
+identity and expression, level of experience, education, socio-economic status,
9
+nationality, personal appearance, race, religion, or sexual identity
10
+and orientation.
11
+
12
+## Our Standards
13
+
14
+Examples of behavior that contributes to a positive environment:
15
+
16
+- Using welcoming and inclusive language
17
+- Being respectful of differing viewpoints and experiences
18
+- Gracefully accepting constructive criticism
19
+- Focusing on what is best for the community
20
+
21
+Examples of unacceptable behavior:
22
+
23
+- Trolling, insulting or derogatory comments, and personal or political attacks
24
+- Public or private harassment
25
+- Publishing others' private information without explicit permission
26
+- Other conduct which could reasonably be considered inappropriate
27
+
28
+## Enforcement
29
+
30
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
31
+reported to the project team at **[email protected]**. All complaints will be
32
+reviewed and investigated.
33
+
34
+## Attribution
35
+
36
+This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.0.
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,36 @@
1 # Contributor Covenant Code of Conduct
2
3 ## Our Pledge
4
5 We as members, contributors, and leaders pledge to make participation in our
6 community a harassment-free experience for everyone, regardless of age, body
7 size, visible or invisible disability, ethnicity, sex characteristics, gender
8 identity and expression, level of experience, education, socio-economic status,
9 nationality, personal appearance, race, religion, or sexual identity
10 and orientation.
11
12 ## Our Standards
13
14 Examples of behavior that contributes to a positive environment:
15
16 - Using welcoming and inclusive language
17 - Being respectful of differing viewpoints and experiences
18 - Gracefully accepting constructive criticism
19 - Focusing on what is best for the community
20
21 Examples of unacceptable behavior:
22
23 - Trolling, insulting or derogatory comments, and personal or political attacks
24 - Public or private harassment
25 - Publishing others' private information without explicit permission
26 - Other conduct which could reasonably be considered inappropriate
27
28 ## Enforcement
29
30 Instances of abusive, harassing, or otherwise unacceptable behavior may be
31 reported to the project team at **[email protected]**. All complaints will be
32 reviewed and investigated.
33
34 ## Attribution
35
36 This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.0.
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -0,0 +1,32 @@
1
+# Contributing to Fossilrepo django + htmx
2
+
3
+Thank you for your interest in contributing!
4
+
5
+## Getting Started
6
+
7
+1. Fork the repository
8
+2. Clone your fork
9
+3. Run `docker compose up -d` (or see README.md for stack-specific setup)
10
+4. Create a feature branch from `main`
11
+
12
+## Development Process
13
+
14
+1. Pick an issue from the project board
15
+2. Comment your plan on the issue before starting
16
+3. Create a branch: `feature/issue-number-description` or `fix/issue-number-description`
17
+4. Make your changes following `bootstrap.md` conventions
18
+5. Write or update tests
19
+6. Run lint and tests (see README.md for commands)
20
+7. Submit a pull request
21
+
22
+## Code Style
23
+
24
+See `bootstrap.md` for conventions. Run the linter before committing.
25
+
26
+## Testing
27
+
28
+All new features need tests. All bug fixes need regression tests. Tests must use a real database — never mock.
29
+
30
+## Questions?
31
+
32
+Open an issue or start a discussion in this repository.
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -0,0 +1,32 @@
1 # Contributing to Fossilrepo django + htmx
2
3 Thank you for your interest in contributing!
4
5 ## Getting Started
6
7 1. Fork the repository
8 2. Clone your fork
9 3. Run `docker compose up -d` (or see README.md for stack-specific setup)
10 4. Create a feature branch from `main`
11
12 ## Development Process
13
14 1. Pick an issue from the project board
15 2. Comment your plan on the issue before starting
16 3. Create a branch: `feature/issue-number-description` or `fix/issue-number-description`
17 4. Make your changes following `bootstrap.md` conventions
18 5. Write or update tests
19 6. Run lint and tests (see README.md for commands)
20 7. Submit a pull request
21
22 ## Code Style
23
24 See `bootstrap.md` for conventions. Run the linter before committing.
25
26 ## Testing
27
28 All new features need tests. All bug fixes need regression tests. Tests must use a real database — never mock.
29
30 ## Questions?
31
32 Open an issue or start a discussion in this repository.
+44
--- a/Dockerfile
+++ b/Dockerfile
@@ -0,0 +1,44 @@
1
+# fossilrepo backend — Django + HTMX + Fossil binary
2
+#
3
+# Omnibus: bundles Fossil from source for repo init/management.
4
+
5
+# ── Stage 1: Build Fossil from source ──────────────────────────────────────
6
+
7
+FROM debian:bookworm-slim AS fossil-builder
8
+
9
+ARG FOSSIL_VERSION=2.24
10
+
11
+RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ build-essential curl ca-certificates zlib1g-dev libssl-dev tcl \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+WORKDIR /build
16
+RUN curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \
17
+ -o fossil.tar.gz \
18
+ && tar xzf fossil.tar.gz \
19
+ && cd fossil-src-${FOSSIL_VERSION} \
20
+ && ./configure --prefix=/usr/local --with-openssl=auto --json \
21
+ && make -j$(nproc) \
22
+ && make install
23
+
24
+# ── Stage 2: Runtime image ─────────────────────────────────────────────────
25
+
26
+FROM python:3.12-slim-bookworm
27
+
28
+RUN apt-get update && apt-get install -y --no-install-recommends \
29
+ postgresql-client ca-cnd — Django + HTMX + Fossil binary
30
+#
31
+# Omnibus: bundles Fossil from source for repo init/management.
32
+
33
+# ── Stage 1: Build Fossil from source ──────────────────────────────────────
34
+
35
+FROM debian:bookw# fossilrepo 2>/dev/null || true
36
+
37
+# Create data directory for .fossil filut
38
+
39
+# Create# fossilrepo backend — Django + HTMX + Fossil binary
40
+#
41
+# Omnibus: bundles Fossil kend — Django + HTMX + Fossil binary
42
+#
43
+# Omnibus: bundles Fossil from source for application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
44
+2yiO2m;
--- a/Dockerfile
+++ b/Dockerfile
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/Dockerfile
+++ b/Dockerfile
@@ -0,0 +1,44 @@
1 # fossilrepo backend — Django + HTMX + Fossil binary
2 #
3 # Omnibus: bundles Fossil from source for repo init/management.
4
5 # ── Stage 1: Build Fossil from source ──────────────────────────────────────
6
7 FROM debian:bookworm-slim AS fossil-builder
8
9 ARG FOSSIL_VERSION=2.24
10
11 RUN apt-get update && apt-get install -y --no-install-recommends \
12 build-essential curl ca-certificates zlib1g-dev libssl-dev tcl \
13 && rm -rf /var/lib/apt/lists/*
14
15 WORKDIR /build
16 RUN curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \
17 -o fossil.tar.gz \
18 && tar xzf fossil.tar.gz \
19 && cd fossil-src-${FOSSIL_VERSION} \
20 && ./configure --prefix=/usr/local --with-openssl=auto --json \
21 && make -j$(nproc) \
22 && make install
23
24 # ── Stage 2: Runtime image ─────────────────────────────────────────────────
25
26 FROM python:3.12-slim-bookworm
27
28 RUN apt-get update && apt-get install -y --no-install-recommends \
29 postgresql-client ca-cnd — Django + HTMX + Fossil binary
30 #
31 # Omnibus: bundles Fossil from source for repo init/management.
32
33 # ── Stage 1: Build Fossil from source ──────────────────────────────────────
34
35 FROM debian:bookw# fossilrepo 2>/dev/null || true
36
37 # Create data directory for .fossil filut
38
39 # Create# fossilrepo backend — Django + HTMX + Fossil binary
40 #
41 # Omnibus: bundles Fossil kend — Django + HTMX + Fossil binary
42 #
43 # Omnibus: bundles Fossil from source for application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
44 2yiO2m;
+21
--- a/LICENSE
+++ b/LICENSE
@@ -0,0 +1,21 @@
1
+MIT License
2
+
3
+Copyright (c) 2026 Conflict LLC
4
+
5
+Permission is hereby granted, free of charge, to any person obtaining a copy
6
+of this software and associated documentation files (the "Software"), to deal
7
+in the Software without restriction, including without limitation the rights
8
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+copies of the Software, and to permit persons to whom the Software is
10
+furnished to do so, subject to the following conditions:
11
+
12
+The above copyright notice and this permission notice shall be included in all
13
+copies or substantial portions of the Software.
14
+
15
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+SOFTWARE.
--- a/LICENSE
+++ b/LICENSE
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/LICENSE
+++ b/LICENSE
@@ -0,0 +1,21 @@
1 MIT License
2
3 Copyright (c) 2026 Conflict LLC
4
5 Permission is hereby granted, free of charge, to any person obtaining a copy
6 of this software and associated documentation files (the "Software"), to deal
7 in the Software without restriction, including without limitation the rights
8 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 copies of the Software, and to permit persons to whom the Software is
10 furnished to do so, subject to the following conditions:
11
12 The above copyright notice and this permission notice shall be included in all
13 copies or substantial portions of the Software.
14
15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 SOFTWARE.
+62
--- a/Makefile
+++ b/Makefile
@@ -0,0 +1,62 @@
1
+.PHONY: up down build logs shell migrate migrations seed test lint check superuser ps
2
+
3
+up:
4
+ docker compose up -d
5
+
6
+down:
7
+ docker compose down
8
+
9
+build:
10
+ docker compose up -d --build
11
+
12
+restart:
13
+ docker compose restart backend
14
+
15
+logs:
16
+ docker compose logs -f backend
17
+
18
+shell:
19
+ docker compose exec backend bash
20
+
21
+migrate:
22
+ docker compose exec backend python manage.py migrate
23
+
24
+migrations:
25
+ docker compose exec backend python manage.py makemigrations $(app)
26
+
27
+seed:
28
+ifdef flush
29
+ docker compose exec backend python manage.py seed --flush
30
+else
31
+ docker compose exec backend python manage.py seed
32
+endif
33
+
34
+test:
35
+ docker compose exec backend python -m pytest --cov --cov-report=term-missing -v
36
+
37
+lint:
38
+ docker compose exec backend python -m ruff check . && docker compose exec backend python -m ruff format --check .
39
+
40
+check: lint test
41
+
42
+superuser:
43
+ docker compose exec backend python manage.py createsuperuser
44
+
45
+ps:
46
+ docker compose ps
47
+
48
+# Local dev (no Docker)
49
+local-install:
50
+ uv sync --all-extras
51
+
52
+local-migrate:
53
+ uv run python manage.py migrate
54
+
55
+local-run:
56
+ uv run python manage.py runserver
57
+
58
+local-test:
59
+ uv run python -m pytest --cov --cov-report=term-missing -v
60
+
61
+local-lint:
62
+ uv run ruff check . && uv run ruff format --check .
--- a/Makefile
+++ b/Makefile
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/Makefile
+++ b/Makefile
@@ -0,0 +1,62 @@
1 .PHONY: up down build logs shell migrate migrations seed test lint check superuser ps
2
3 up:
4 docker compose up -d
5
6 down:
7 docker compose down
8
9 build:
10 docker compose up -d --build
11
12 restart:
13 docker compose restart backend
14
15 logs:
16 docker compose logs -f backend
17
18 shell:
19 docker compose exec backend bash
20
21 migrate:
22 docker compose exec backend python manage.py migrate
23
24 migrations:
25 docker compose exec backend python manage.py makemigrations $(app)
26
27 seed:
28 ifdef flush
29 docker compose exec backend python manage.py seed --flush
30 else
31 docker compose exec backend python manage.py seed
32 endif
33
34 test:
35 docker compose exec backend python -m pytest --cov --cov-report=term-missing -v
36
37 lint:
38 docker compose exec backend python -m ruff check . && docker compose exec backend python -m ruff format --check .
39
40 check: lint test
41
42 superuser:
43 docker compose exec backend python manage.py createsuperuser
44
45 ps:
46 docker compose ps
47
48 # Local dev (no Docker)
49 local-install:
50 uv sync --all-extras
51
52 local-migrate:
53 uv run python manage.py migrate
54
55 local-run:
56 uv run python manage.py runserver
57
58 local-test:
59 uv run python -m pytest --cov --cov-report=term-missing -v
60
61 local-lint:
62 uv run ruff check . && uv run ruff format --check .
+13
--- a/README.md
+++ b/README.md
@@ -0,0 +1,13 @@
1
+# asn't changDjango + HTMX
2
+
3
+Server-rendered Django with HTMX for dynamic behavior and Alpine.js for lightweight client state. Tailwind CSS for styling. Choose this for content-heavy CRUD, admin-centric tools, and apps where server-rendered sted Fossil forge with a modern web interface.**
4
+
5
+Fossilrepo wraps [Fossil SCM](https://fossil-scm.org) with a Django + HTMX management layer, replacing Fossil's built-in web UI with a GitHub/GitLab-caliber experience while preserving everything that makes Fossil unique: single-file repos, built-in wiki, tickets, forum, and technotes.
6
+
7
+## Why Fossilrepo?
8
+
9
+Fossil is the most underrated version control system. Every repository is a single SQLite file containing your code, wiki, tickets, forum, and technotes. No external services, no complex setup. But its web UI hasn't changed since 1998.
10
+
11
+Fossilrepo fixes that. You get:
12
+
13
+- A modern dark/light UI built with Django, HTMX, Al
--- a/README.md
+++ b/README.md
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/README.md
+++ b/README.md
@@ -0,0 +1,13 @@
1 # asn't changDjango + HTMX
2
3 Server-rendered Django with HTMX for dynamic behavior and Alpine.js for lightweight client state. Tailwind CSS for styling. Choose this for content-heavy CRUD, admin-centric tools, and apps where server-rendered sted Fossil forge with a modern web interface.**
4
5 Fossilrepo wraps [Fossil SCM](https://fossil-scm.org) with a Django + HTMX management layer, replacing Fossil's built-in web UI with a GitHub/GitLab-caliber experience while preserving everything that makes Fossil unique: single-file repos, built-in wiki, tickets, forum, and technotes.
6
7 ## Why Fossilrepo?
8
9 Fossil is the most underrated version control system. Every repository is a single SQLite file containing your code, wiki, tickets, forum, and technotes. No external services, no complex setup. But its web UI hasn't changed since 1998.
10
11 Fossilrepo fixes that. You get:
12
13 - A modern dark/light UI built with Django, HTMX, Al
+29
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
1
+# Security Policy
2
+
3
+## Reporting a Vulnerability
4
+
5
+If you discover a security vulnerability in Fossilrepo, please report it responsibly.
6
+
7
+**Do nInstead, et open a public issue.**
8
+
9
+Email **[email protected]** with:
10
+
11
+- Description of the vulnerability
12
+- Steps to reproduce
13
+- Potential impact
14
+- Suggested fix (if any)
15
+
16
+We will acknowledge your report within 48 hours and aim to release a fix within 7 days for critical issues.
17
+
18
+## Supported Versions
19
+
20
+| Version | Supported |
21
+| -----Best Practiceslocalauth`)
22
+
23
+### Deployment:
24
+out one when `DEBUG=Facredentials (database, MinIO, session secret)
25
+- Use HTTPS in production
26
+- Set `NODE_ENV=pr_ORIGINS` and `CSRF_TRUSTED_ORIGINS` to your domain
27
+- Review Constance settings in Django admin (OAuth secrets, S3 credentials)
28
+- Use a reverse proxy (Caddy/nginx) for SSL termination
29
+- Keep t
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -0,0 +1,29 @@
1 # Security Policy
2
3 ## Reporting a Vulnerability
4
5 If you discover a security vulnerability in Fossilrepo, please report it responsibly.
6
7 **Do nInstead, et open a public issue.**
8
9 Email **[email protected]** with:
10
11 - Description of the vulnerability
12 - Steps to reproduce
13 - Potential impact
14 - Suggested fix (if any)
15
16 We will acknowledge your report within 48 hours and aim to release a fix within 7 days for critical issues.
17
18 ## Supported Versions
19
20 | Version | Supported |
21 | -----Best Practiceslocalauth`)
22
23 ### Deployment:
24 out one when `DEBUG=Facredentials (database, MinIO, session secret)
25 - Use HTTPS in production
26 - Set `NODE_ENV=pr_ORIGINS` and `CSRF_TRUSTED_ORIGINS` to your domain
27 - Review Constance settings in Django admin (OAuth secrets, S3 credentials)
28 - Use a reverse proxy (Caddy/nginx) for SSL termination
29 - Keep t
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -0,0 +1,57 @@
1
+# CLAUDE.md -- fossilrepo
2
+
3
+## Project Overview
4
+
5
+fossilrepo is a self-hosted Fossil SCM server infrastructure tool. It provides Docker + Caddy + Litestream hosting for Fossil repositories, a CLI wrapper around fossil commands, and a sync bridge to mirror Fossil repos to GitHub/GitLab.
6
+
7
+Open source (MIT). Part of the CONFLICT ecosystem.
8
+
9
+## Repository Structure
10
+
11
+```
12
+fossilrepo/
13
+├── fossilrepo/ # Python package
14
+│ ├── server/ # Fossil server management (Docker, Caddy, Litestream)
15
+│ │ ├── config.py # Pydantic server configuration
16
+│ │ └── manager.py # Repo lifecycle (create, delete, list)
17
+│ ├── sync/ # Fossil → Git mirror
18
+│ │ ├── mirror.py # Core sync logic (commits, tickets, wiki)
19
+│ │ └── mappings.py # Data models for Fossil↔Git mappings
20
+│ └── cli/ # Click CLI
21
+│ └── main.py # CLI entrypoint (server, repo, sync commands)
22
+├── docker/ # Container configs
23
+│ ├── Dockerfile # Fossil + Caddy + Litestream
24
+│ ├── docker-compose.yml # Local dev stack
25
+│ ├── Caddyfile # Subdomain routing
26
+│ └── litestream.yml # S3 replication
27
+├── tests/ # pytest, mirrors fossilrepo/
28
+├── docs/ # Architecture, guides
29
+├── fossil-platform/ # Old exploration (Flask + React), kept for reference
30
+├── bootstrap.md # Project bootstrap doc — read first
31
+└── AGENTS.md # Agent conventions pointer
32
+```
33
+
34
+## Key Conventions
35
+
36
+- Python 3.11+, typed with Pydantic models
37
+- Click for CLI, Rich for terminal output
38
+- Ruff for linting, pytest for testing
39
+- Fossil is the source of truth; Git remotes are downstream mirrors
40
+- Server infra: Docker + Caddy (SSL, subdomain routing) + Litestream (S3 replication)
41
+- Each repo is a single .fossil file (SQLite) — Litestream replicates it continuously
42
+
43
+## Development
44
+
45
+```bash
46
+pip install -e ".[dev]"
47
+pytest
48
+ruff check .
49
+```
50
+
51
+## CLI
52
+
53
+```bash
54
+fossilrepo server start|stop|status
55
+fossilrepo repo create|list|delete
56
+fossilrepo sync run|status
57
+```
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_CLAUDE.md
+++ b/_old_CLAUDE.md
@@ -0,0 +1,57 @@
1 # CLAUDE.md -- fossilrepo
2
3 ## Project Overview
4
5 fossilrepo is a self-hosted Fossil SCM server infrastructure tool. It provides Docker + Caddy + Litestream hosting for Fossil repositories, a CLI wrapper around fossil commands, and a sync bridge to mirror Fossil repos to GitHub/GitLab.
6
7 Open source (MIT). Part of the CONFLICT ecosystem.
8
9 ## Repository Structure
10
11 ```
12 fossilrepo/
13 ├── fossilrepo/ # Python package
14 │ ├── server/ # Fossil server management (Docker, Caddy, Litestream)
15 │ │ ├── config.py # Pydantic server configuration
16 │ │ └── manager.py # Repo lifecycle (create, delete, list)
17 │ ├── sync/ # Fossil → Git mirror
18 │ │ ├── mirror.py # Core sync logic (commits, tickets, wiki)
19 │ │ └── mappings.py # Data models for Fossil↔Git mappings
20 │ └── cli/ # Click CLI
21 │ └── main.py # CLI entrypoint (server, repo, sync commands)
22 ├── docker/ # Container configs
23 │ ├── Dockerfile # Fossil + Caddy + Litestream
24 │ ├── docker-compose.yml # Local dev stack
25 │ ├── Caddyfile # Subdomain routing
26 │ └── litestream.yml # S3 replication
27 ├── tests/ # pytest, mirrors fossilrepo/
28 ├── docs/ # Architecture, guides
29 ├── fossil-platform/ # Old exploration (Flask + React), kept for reference
30 ├── bootstrap.md # Project bootstrap doc — read first
31 └── AGENTS.md # Agent conventions pointer
32 ```
33
34 ## Key Conventions
35
36 - Python 3.11+, typed with Pydantic models
37 - Click for CLI, Rich for terminal output
38 - Ruff for linting, pytest for testing
39 - Fossil is the source of truth; Git remotes are downstream mirrors
40 - Server infra: Docker + Caddy (SSL, subdomain routing) + Litestream (S3 replication)
41 - Each repo is a single .fossil file (SQLite) — Litestream replicates it continuously
42
43 ## Development
44
45 ```bash
46 pip install -e ".[dev]"
47 pytest
48 ruff check .
49 ```
50
51 ## CLI
52
53 ```bash
54 fossilrepo server start|stop|status
55 fossilrepo repo create|list|delete
56 fossilrepo sync run|status
57 ```
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -0,0 +1,89 @@
1
+# fossilrepo — bootstrap
2
+
3
+Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups — all powered by Fossil SCM.
4
+
5
+Think GitLab Omnibus, but for Fossil.
6
+
7
+---
8
+
9
+## Why Fossil
10
+
11
+A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable — hand the file to someone and they have everything.
12
+
13
+For teams running CI agents or automation:
14
+- Agents commit, file tickets, and update the wiki through one CLI and one protocol
15
+- No API rate limits when many agents are pushing simultaneously
16
+- The `.fossil` file IS the project artifact — a self-contained archive
17
+- Litestream replicates it to S3 continuously — backup and point-in-time recovery for free
18
+
19
+Fossil also has a built-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
20
+
21
+---
22
+
23
+## What fossilrepo Does
24
+
25
+fossilrepo packages everything needed to run a production Fossil server into one installable unit:
26
+
27
+- **Fossil server** — serves all repos from a single process
28
+- **Caddy** — SSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
29
+- **Litestream** — continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
30
+- **CLI** — repo lifecycle management (create, list, delete) and sync tooling
31
+- **Sync bridge** — mirror Fossil repos to GitHub/GitLab as downstream read-only copies
32
+
33
+New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
34
+
35
+---
36
+
37
+## Architecture
38
+
39
+```
40
+fossilrepo/
41
+├── server/ # Fossil server infra — Docker, Caddy, Litestream
42
+├── sync/ # Fossil → GitHub/GitLab mirror
43
+├── cli/ # fossilrepo CLI wrapper
44
+└── docs/ # Architecture, guides
45
+```
46
+
47
+### Server Stack
48
+
49
+```
50
+Caddy (SSL termination, routing, subdomain per repo)
51
+ └── fossil server --repolist /data/repos/
52
+ └── /data/repos/
53
+ ├── projecta.fossil
54
+ ├── projectb.fossil
55
+ └── ...
56
+
57
+Litestream → S3/MinIO (continuous replication, point-in-time recovery)
58
+```
59
+
60
+One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
61
+
62
+### Sync Bridge
63
+
64
+Mirrors Fossil to GitHub/GitLab as a downstream copy. Fossil is the source of truth.
65
+
66
+Maps:
67
+- Fossil commits → Git commits
68
+- Fossil tickets → GitHub/GitLab Issues (optional, configurable)
69
+- Fossil wiki → repo docs (optional, configurable)
70
+
71
+Triggered on demand or on schedule.
72
+
73
+---
74
+
75
+## Platform Vision (fossilrepos.com)
76
+
77
+GitLab model:
78
+- **Self-hosted** — open source, run it yourself. fossilrepo is the tool.
79
+- **Managed** — fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
80
+
81
+The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite — Fossil already does the hard parts. The value is the hosting and UX polish.
82
+
83
+Not being built yet — get the self-hosted tool right first.
84
+
85
+---
86
+
87
+## License
88
+
89
+MIT.
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_bootstrap.md
+++ b/_old_bootstrap.md
@@ -0,0 +1,89 @@
1 # fossilrepo — bootstrap
2
3 Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups — all powered by Fossil SCM.
4
5 Think GitLab Omnibus, but for Fossil.
6
7 ---
8
9 ## Why Fossil
10
11 A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable — hand the file to someone and they have everything.
12
13 For teams running CI agents or automation:
14 - Agents commit, file tickets, and update the wiki through one CLI and one protocol
15 - No API rate limits when many agents are pushing simultaneously
16 - The `.fossil` file IS the project artifact — a self-contained archive
17 - Litestream replicates it to S3 continuously — backup and point-in-time recovery for free
18
19 Fossil also has a built-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
20
21 ---
22
23 ## What fossilrepo Does
24
25 fossilrepo packages everything needed to run a production Fossil server into one installable unit:
26
27 - **Fossil server** — serves all repos from a single process
28 - **Caddy** — SSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
29 - **Litestream** — continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
30 - **CLI** — repo lifecycle management (create, list, delete) and sync tooling
31 - **Sync bridge** — mirror Fossil repos to GitHub/GitLab as downstream read-only copies
32
33 New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
34
35 ---
36
37 ## Architecture
38
39 ```
40 fossilrepo/
41 ├── server/ # Fossil server infra — Docker, Caddy, Litestream
42 ├── sync/ # Fossil → GitHub/GitLab mirror
43 ├── cli/ # fossilrepo CLI wrapper
44 └── docs/ # Architecture, guides
45 ```
46
47 ### Server Stack
48
49 ```
50 Caddy (SSL termination, routing, subdomain per repo)
51 └── fossil server --repolist /data/repos/
52 └── /data/repos/
53 ├── projecta.fossil
54 ├── projectb.fossil
55 └── ...
56
57 Litestream → S3/MinIO (continuous replication, point-in-time recovery)
58 ```
59
60 One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
61
62 ### Sync Bridge
63
64 Mirrors Fossil to GitHub/GitLab as a downstream copy. Fossil is the source of truth.
65
66 Maps:
67 - Fossil commits → Git commits
68 - Fossil tickets → GitHub/GitLab Issues (optional, configurable)
69 - Fossil wiki → repo docs (optional, configurable)
70
71 Triggered on demand or on schedule.
72
73 ---
74
75 ## Platform Vision (fossilrepos.com)
76
77 GitLab model:
78 - **Self-hosted** — open source, run it yourself. fossilrepo is the tool.
79 - **Managed** — fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
80
81 The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite — Fossil already does the hard parts. The value is the hosting and UX polish.
82
83 Not being built yet — get the self-hosted tool right first.
84
85 ---
86
87 ## License
88
89 MIT.
--- a/_old_fossilrepo/__init__.py
+++ b/_old_fossilrepo/__init__.py
@@ -0,0 +1 @@
1
+__version__ = "0.1.0"
--- a/_old_fossilrepo/__init__.py
+++ b/_old_fossilrepo/__init__.py
@@ -0,0 +1 @@
 
--- a/_old_fossilrepo/__init__.py
+++ b/_old_fossilrepo/__init__.py
@@ -0,0 +1 @@
1 __version__ = "0.1.0"

No diff available

--- a/_old_fossilrepo/cli/main.py
+++ b/_old_fossilrepo/cli/main.py
@@ -0,0 +1,104 @@
1
+"""fossilrepo CLI — manage Fossil servers, repos, and Git sync."""
2
+
3
+import click
4
+from rich.console import Console
5
+
6
+console = Console()
7
+
8
+
9
+@click.group()
10
+@click.version_option(package_name="fossilrepo")
11
+def cli() -> None:
12
+ """fossilrepo — self-hosted Fossil SCM infrastructure."""
13
+
14
+
15
+# ---------------------------------------------------------------------------
16
+# Server commands
17
+# ---------------------------------------------------------------------------
18
+
19
+
20
+@cli.group()
21
+def server() -> None:
22
+ """Manage the Fossil server."""
23
+
24
+
25
+@server.command()
26
+def start() -> None:
27
+ """Start the Fossil server (Docker + Caddy + Litestream)."""
28
+ console.print("[bold]Starting Fossil server...[/bold]")
29
+ raise NotImplementedError
30
+
31
+
32
+@server.command()
33
+def stop() -> None:
34
+ """Stop the Fossil server."""
35
+ console.print("[bold]Stopping Fossil server...[/bold]")
36
+ raise NotImplementedError
37
+
38
+
39
+@server.command()
40
+def status() -> None:
41
+ """Show Fossil server status."""
42
+ console.print("[bold]Server status:[/bold]")
43
+ raise NotImplementedError
44
+
45
+
46
+# ---------------------------------------------------------------------------
47
+# Repo commands
48
+# ---------------------------------------------------------------------------
49
+
50
+
51
+@cli.group()
52
+def repo() -> None:
53
+ """Manage Fossil repositories."""
54
+
55
+
56
+@repo.command()
57
+@click.argument("name")
58
+def create(name: str) -> None:
59
+ """Create a new Fossil repository."""
60
+ console.print(f"[bold]Creating repo:[/bold] {name}")
61
+ raise NotImplementedError
62
+
63
+
64
+@repo.command(name="list")
65
+def list_repos() -> None:
66
+ """List all Fossil repositories."""
67
+ raise NotImplementedError
68
+
69
+
70
+@repo.command()
71
+@click.argument("name")
72
+def delete(name: str) -> None:
73
+ """Delete a Fossil repository."""
74
+ console.print(f"[bold]Deleting repo:[/bold] {name}")
75
+ raise NotImplementedError
76
+
77
+
78
+# ---------------------------------------------------------------------------
79
+# Sync commands
80
+# ---------------------------------------------------------------------------
81
+
82
+
83
+@cli.group()
84
+def sync() -> None:
85
+ """Sync Fossil repos to GitHub/GitLab."""
86
+
87
+
88
+@sync.command()
89
+@click.argument("repo_name")
90
+@click.option("--remote", required=True, help="Git remote URL to sync to.")
91
+@click.option("--tickets/--no-tickets", default=False, help="Sync tickets as issues.")
92
+@click.option("--wiki/--no-wiki", default=False, help="Sync wiki pages.")
93
+def run(repo_name: str, remote: str, tickets: bool, wiki: bool) -> None:
94
+ """Run a sync from a Fossil repo to a Git remote."""
95
+ console.print(f"[bold]Syncing[/bold] {repo_name} -> {remote}")
96
+ raise NotImplementedError
97
+
98
+
99
+@sync.command()
100
+@click.argument("repo_name")
101
+def status(repo_name: str) -> None: # noqa: F811
102
+ """Show sync status for a repository."""
103
+ console.print(f"[bold]Sync status for:[/bold] {repo_name}")
104
+ raise NotImplementedError
--- a/_old_fossilrepo/cli/main.py
+++ b/_old_fossilrepo/cli/main.py
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/cli/main.py
+++ b/_old_fossilrepo/cli/main.py
@@ -0,0 +1,104 @@
1 """fossilrepo CLI — manage Fossil servers, repos, and Git sync."""
2
3 import click
4 from rich.console import Console
5
6 console = Console()
7
8
9 @click.group()
10 @click.version_option(package_name="fossilrepo")
11 def cli() -> None:
12 """fossilrepo — self-hosted Fossil SCM infrastructure."""
13
14
15 # ---------------------------------------------------------------------------
16 # Server commands
17 # ---------------------------------------------------------------------------
18
19
20 @cli.group()
21 def server() -> None:
22 """Manage the Fossil server."""
23
24
25 @server.command()
26 def start() -> None:
27 """Start the Fossil server (Docker + Caddy + Litestream)."""
28 console.print("[bold]Starting Fossil server...[/bold]")
29 raise NotImplementedError
30
31
32 @server.command()
33 def stop() -> None:
34 """Stop the Fossil server."""
35 console.print("[bold]Stopping Fossil server...[/bold]")
36 raise NotImplementedError
37
38
39 @server.command()
40 def status() -> None:
41 """Show Fossil server status."""
42 console.print("[bold]Server status:[/bold]")
43 raise NotImplementedError
44
45
46 # ---------------------------------------------------------------------------
47 # Repo commands
48 # ---------------------------------------------------------------------------
49
50
51 @cli.group()
52 def repo() -> None:
53 """Manage Fossil repositories."""
54
55
56 @repo.command()
57 @click.argument("name")
58 def create(name: str) -> None:
59 """Create a new Fossil repository."""
60 console.print(f"[bold]Creating repo:[/bold] {name}")
61 raise NotImplementedError
62
63
64 @repo.command(name="list")
65 def list_repos() -> None:
66 """List all Fossil repositories."""
67 raise NotImplementedError
68
69
70 @repo.command()
71 @click.argument("name")
72 def delete(name: str) -> None:
73 """Delete a Fossil repository."""
74 console.print(f"[bold]Deleting repo:[/bold] {name}")
75 raise NotImplementedError
76
77
78 # ---------------------------------------------------------------------------
79 # Sync commands
80 # ---------------------------------------------------------------------------
81
82
83 @cli.group()
84 def sync() -> None:
85 """Sync Fossil repos to GitHub/GitLab."""
86
87
88 @sync.command()
89 @click.argument("repo_name")
90 @click.option("--remote", required=True, help="Git remote URL to sync to.")
91 @click.option("--tickets/--no-tickets", default=False, help="Sync tickets as issues.")
92 @click.option("--wiki/--no-wiki", default=False, help="Sync wiki pages.")
93 def run(repo_name: str, remote: str, tickets: bool, wiki: bool) -> None:
94 """Run a sync from a Fossil repo to a Git remote."""
95 console.print(f"[bold]Syncing[/bold] {repo_name} -> {remote}")
96 raise NotImplementedError
97
98
99 @sync.command()
100 @click.argument("repo_name")
101 def status(repo_name: str) -> None: # noqa: F811
102 """Show sync status for a repository."""
103 console.print(f"[bold]Sync status for:[/bold] {repo_name}")
104 raise NotImplementedError
--- a/_old_fossilrepo/server/config.py
+++ b/_old_fossilrepo/server/config.py
@@ -0,0 +1,66 @@
1
+"""Server configuration for Fossil repository hosting."""
2
+
3
+from pathlib import Path
4
+
5
+from pydantic import Field
6
+from pydantic_settings import BaseSettings
7
+
8
+
9
+class ServerConfig(BaseSettings):
10
+ """Configuration for the Fossil server infrastructure.
11
+
12
+ Values are loaded from environment variables prefixed with FOSSILREPO_.
13
+ For example, FOSSILREPO_DATA_DIR sets data_dir.
14
+ """
15
+
16
+ model_config = {"env_prefix": "FOSSILREPO_"}
17
+
18
+ data_dir: Path = Field(
19
+ default=Path("/data/repos"),
20
+ description="Directory where .fossil repository files are stored.",
21
+ )
22
+
23
+ caddy_domain: str = Field(
24
+ default="localhost",
25
+ description="Base domain for subdomain routing (e.g., fossilrepos.io).",
26
+ )
27
+
28
+ caddy_config_path: Path = Field(
29
+ default=Path("/etc/caddy/Caddyfile"),
30
+ description="Path to the Caddy configuration file.",
31
+ )
32
+
33
+ fossil_port: int = Field(
34
+ default=8080,
35
+ description="Port the fossil server listens on.",
36
+ )
37
+
38
+ s3_bucket: str = Field(
39
+ default="",
40
+ description="S3 bucket for Litestream replication.",
41
+ )
42
+
43
+ s3_endpoint: str = Field(
44
+ default="",
45
+ description="S3-compatible endpoint URL (for MinIO, R2, etc.).",
46
+ )
47
+
48
+ s3_access_key_id: str = Field(
49
+ default="",
50
+ description="AWS access key ID for S3 replication.",
51
+ )
52
+
53
+ s3_secret_access_key: str = Field(
54
+ default="",
55
+ description="AWS secret access key for S3 replication.",
56
+ )
57
+
58
+ s3_region: str = Field(
59
+ default="us-east-1",
60
+ description="AWS region for S3 bucket.",
61
+ )
62
+
63
+ litestream_config_path: Path = Field(
64
+ default=Path("/etc/litestream.yml"),
65
+ description="Path to the Litestream configuration file.",
66
+ )
--- a/_old_fossilrepo/server/config.py
+++ b/_old_fossilrepo/server/config.py
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/server/config.py
+++ b/_old_fossilrepo/server/config.py
@@ -0,0 +1,66 @@
1 """Server configuration for Fossil repository hosting."""
2
3 from pathlib import Path
4
5 from pydantic import Field
6 from pydantic_settings import BaseSettings
7
8
9 class ServerConfig(BaseSettings):
10 """Configuration for the Fossil server infrastructure.
11
12 Values are loaded from environment variables prefixed with FOSSILREPO_.
13 For example, FOSSILREPO_DATA_DIR sets data_dir.
14 """
15
16 model_config = {"env_prefix": "FOSSILREPO_"}
17
18 data_dir: Path = Field(
19 default=Path("/data/repos"),
20 description="Directory where .fossil repository files are stored.",
21 )
22
23 caddy_domain: str = Field(
24 default="localhost",
25 description="Base domain for subdomain routing (e.g., fossilrepos.io).",
26 )
27
28 caddy_config_path: Path = Field(
29 default=Path("/etc/caddy/Caddyfile"),
30 description="Path to the Caddy configuration file.",
31 )
32
33 fossil_port: int = Field(
34 default=8080,
35 description="Port the fossil server listens on.",
36 )
37
38 s3_bucket: str = Field(
39 default="",
40 description="S3 bucket for Litestream replication.",
41 )
42
43 s3_endpoint: str = Field(
44 default="",
45 description="S3-compatible endpoint URL (for MinIO, R2, etc.).",
46 )
47
48 s3_access_key_id: str = Field(
49 default="",
50 description="AWS access key ID for S3 replication.",
51 )
52
53 s3_secret_access_key: str = Field(
54 default="",
55 description="AWS secret access key for S3 replication.",
56 )
57
58 s3_region: str = Field(
59 default="us-east-1",
60 description="AWS region for S3 bucket.",
61 )
62
63 litestream_config_path: Path = Field(
64 default=Path("/etc/litestream.yml"),
65 description="Path to the Litestream configuration file.",
66 )
--- a/_old_fossilrepo/server/manager.py
+++ b/_old_fossilrepo/server/manager.py
@@ -0,0 +1,77 @@
1
+"""Fossil repository management — create, delete, list, inspect repos."""
2
+
3
+from pathlib import Path
4
+
5
+from fossilrepo.server.config import ServerConfig
6
+
7
+
8
+class RepoInfo:
9
+ """Information about a single Fossil repository."""
10
+
11
+ def __init__(self, name: str, path: Path, size_bytes: int) -> None:
12
+ self.name = name
13
+ self.path = path
14
+ self.size_bytes = size_bytes
15
+
16
+
17
+class FossilRepoManager:
18
+ """Manages Fossil repositories on the server.
19
+
20
+ Handles repo lifecycle: creation via `fossil init`, deletion (soft — moves
21
+ to trash), listing, and metadata inspection. Coordinates with Litestream
22
+ for S3 replication of new repos.
23
+ """
24
+
25
+ def __init__(self, config: ServerConfig | None = None) -> None:
26
+ self.config = config or ServerConfig()
27
+
28
+ def create_repo(self, name: str) -> RepoInfo:
29
+ """Create a new Fossil repository.
30
+
31
+ Runs `fossil init` to create the .fossil file in the data directory,
32
+ registers the repo with Caddy for subdomain routing, and ensures
33
+ Litestream picks up the new file for replication.
34
+
35
+ Args:
36
+ name: Repository name. Used as the subdomain and filename.
37
+
38
+ Returns:
39
+ RepoInfo for the newly created repository.
40
+ """
41
+ raise NotImplementedError
42
+
43
+ def delete_repo(self, name: str) -> None:
44
+ """Soft-delete a Fossil repository.
45
+
46
+ Moves the .fossil file to a trash directory rather than deleting it.
47
+ Removes the Caddy subdomain route. Litestream retains the S3 replica.
48
+
49
+ Args:
50
+ name: Repository name to delete.
51
+ """
52
+ raise NotImplementedError
53
+
54
+ def list_repos(self) -> list[RepoInfo]:
55
+ """List all active Fossil repositories.
56
+
57
+ Scans the data directory for .fossil files and returns metadata
58
+ for each.
59
+
60
+ Returns:
61
+ List of RepoInfo objects for all active repositories.
62
+ """
63
+ raise NotImplementedError
64
+
65
+ def get_repo_info(self, name: str) -> RepoInfo:
66
+ """Get detailed information about a specific repository.
67
+
68
+ Args:
69
+ name: Repository name to inspect.
70
+
71
+ Returns:
72
+ RepoInfo with metadata about the repository.
73
+
74
+ Raises:
75
+ FileNotFoundError: If the repository does not exist.
76
+ """
77
+ raise NotImplementedError
--- a/_old_fossilrepo/server/manager.py
+++ b/_old_fossilrepo/server/manager.py
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/server/manager.py
+++ b/_old_fossilrepo/server/manager.py
@@ -0,0 +1,77 @@
1 """Fossil repository management — create, delete, list, inspect repos."""
2
3 from pathlib import Path
4
5 from fossilrepo.server.config import ServerConfig
6
7
8 class RepoInfo:
9 """Information about a single Fossil repository."""
10
11 def __init__(self, name: str, path: Path, size_bytes: int) -> None:
12 self.name = name
13 self.path = path
14 self.size_bytes = size_bytes
15
16
17 class FossilRepoManager:
18 """Manages Fossil repositories on the server.
19
20 Handles repo lifecycle: creation via `fossil init`, deletion (soft — moves
21 to trash), listing, and metadata inspection. Coordinates with Litestream
22 for S3 replication of new repos.
23 """
24
25 def __init__(self, config: ServerConfig | None = None) -> None:
26 self.config = config or ServerConfig()
27
28 def create_repo(self, name: str) -> RepoInfo:
29 """Create a new Fossil repository.
30
31 Runs `fossil init` to create the .fossil file in the data directory,
32 registers the repo with Caddy for subdomain routing, and ensures
33 Litestream picks up the new file for replication.
34
35 Args:
36 name: Repository name. Used as the subdomain and filename.
37
38 Returns:
39 RepoInfo for the newly created repository.
40 """
41 raise NotImplementedError
42
43 def delete_repo(self, name: str) -> None:
44 """Soft-delete a Fossil repository.
45
46 Moves the .fossil file to a trash directory rather than deleting it.
47 Removes the Caddy subdomain route. Litestream retains the S3 replica.
48
49 Args:
50 name: Repository name to delete.
51 """
52 raise NotImplementedError
53
54 def list_repos(self) -> list[RepoInfo]:
55 """List all active Fossil repositories.
56
57 Scans the data directory for .fossil files and returns metadata
58 for each.
59
60 Returns:
61 List of RepoInfo objects for all active repositories.
62 """
63 raise NotImplementedError
64
65 def get_repo_info(self, name: str) -> RepoInfo:
66 """Get detailed information about a specific repository.
67
68 Args:
69 name: Repository name to inspect.
70
71 Returns:
72 RepoInfo with metadata about the repository.
73
74 Raises:
75 FileNotFoundError: If the repository does not exist.
76 """
77 raise NotImplementedError

No diff available

--- a/_old_fossilrepo/sync/mappings.py
+++ b/_old_fossilrepo/sync/mappings.py
@@ -0,0 +1,39 @@
1
+"""Data models for Fossil-to-Git sync mappings."""
2
+
3
+from datetime import datetime
4
+
5
+from pydantic import BaseModel, Field
6
+
7
+
8
+class CommitMapping(BaseModel):
9
+ """Maps a Fossil checkin to a Git commit."""
10
+
11
+ fossil_hash: str = Field(description="Fossil checkin hash (SHA1).")
12
+ git_sha: str = Field(description="Corresponding Git commit SHA.")
13
+ timestamp: datetime = Field(description="Commit timestamp.")
14
+ message: str = Field(description="Commit message.")
15
+ author: str = Field(description="Author name.")
16
+
17
+
18
+class TicketMapping(BaseModel):
19
+ """Maps a Fossil ticket to a GitHub/GitLab issue."""
20
+
21
+ fossil_ticket_id: str = Field(description="Fossil ticket UUID.")
22
+ remote_issue_number: int = Field(description="GitHub/GitLab issue number.")
23
+ remote_issue_url: str = Field(description="URL to the remote issue.")
24
+ title: str = Field(description="Ticket/issue title.")
25
+ status: str = Field(description="Current status (open, closed, etc.).")
26
+ last_synced: datetime = Field(description="Timestamp of last sync.")
27
+
28
+
29
+class WikiMapping(BaseModel):
30
+ """Maps a Fossil wiki page to a remote doc/wiki page."""
31
+
32
+ fossil_page_name: str = Field(description="Fossil wiki page name.")
33
+ remote_path: str = Field(
34
+ description="Path in the remote repo (e.g., docs/page.md) or wiki URL."
35
+ )
36
+ last_synced: datetime = Field(description="Timestamp of last sync.")
37
+ content_hash: str = Field(
38
+ description="Hash of the content at last sync, for change detection."
39
+ )
--- a/_old_fossilrepo/sync/mappings.py
+++ b/_old_fossilrepo/sync/mappings.py
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/sync/mappings.py
+++ b/_old_fossilrepo/sync/mappings.py
@@ -0,0 +1,39 @@
1 """Data models for Fossil-to-Git sync mappings."""
2
3 from datetime import datetime
4
5 from pydantic import BaseModel, Field
6
7
8 class CommitMapping(BaseModel):
9 """Maps a Fossil checkin to a Git commit."""
10
11 fossil_hash: str = Field(description="Fossil checkin hash (SHA1).")
12 git_sha: str = Field(description="Corresponding Git commit SHA.")
13 timestamp: datetime = Field(description="Commit timestamp.")
14 message: str = Field(description="Commit message.")
15 author: str = Field(description="Author name.")
16
17
18 class TicketMapping(BaseModel):
19 """Maps a Fossil ticket to a GitHub/GitLab issue."""
20
21 fossil_ticket_id: str = Field(description="Fossil ticket UUID.")
22 remote_issue_number: int = Field(description="GitHub/GitLab issue number.")
23 remote_issue_url: str = Field(description="URL to the remote issue.")
24 title: str = Field(description="Ticket/issue title.")
25 status: str = Field(description="Current status (open, closed, etc.).")
26 last_synced: datetime = Field(description="Timestamp of last sync.")
27
28
29 class WikiMapping(BaseModel):
30 """Maps a Fossil wiki page to a remote doc/wiki page."""
31
32 fossil_page_name: str = Field(description="Fossil wiki page name.")
33 remote_path: str = Field(
34 description="Path in the remote repo (e.g., docs/page.md) or wiki URL."
35 )
36 last_synced: datetime = Field(description="Timestamp of last sync.")
37 content_hash: str = Field(
38 description="Hash of the content at last sync, for change detection."
39 )
--- a/_old_fossilrepo/sync/mirror.py
+++ b/_old_fossilrepo/sync/mirror.py
@@ -0,0 +1,86 @@
1
+"""Fossil-to-Git mirror — sync commits, tickets, and wiki to GitHub/GitLab."""
2
+
3
+from pathlib import Path
4
+
5
+from fossilrepo.sync.mappings import CommitMapping, TicketMapping, WikiMapping
6
+
7
+
8
+class FossilMirror:
9
+ """Mirrors a Fossil repository to a Git remote (GitHub or GitLab).
10
+
11
+ Fossil is the source of truth. The Git remote is a downstream mirror
12
+ for ecosystem visibility. Syncs commits, optionally maps tickets to
13
+ issues and wiki pages to docs.
14
+ """
15
+
16
+ def __init__(self, fossil_path: Path, remote_url: str) -> None:
17
+ self.fossil_path = fossil_path
18
+ self.remote_url = remote_url
19
+
20
+ def sync_to_github(
21
+ self,
22
+ *,
23
+ include_tickets: bool = False,
24
+ include_wiki: bool = False,
25
+ ) -> None:
26
+ """Run a full sync to a GitHub repository.
27
+
28
+ Exports Fossil commits to Git format and pushes to the GitHub remote.
29
+ Optionally syncs tickets as GitHub Issues and wiki as repo docs.
30
+
31
+ Args:
32
+ include_tickets: If True, map Fossil tickets to GitHub Issues.
33
+ include_wiki: If True, export Fossil wiki pages to repo docs.
34
+ """
35
+ raise NotImplementedError
36
+
37
+ def sync_to_gitlab(
38
+ self,
39
+ *,
40
+ include_tickets: bool = False,
41
+ include_wiki: bool = False,
42
+ ) -> None:
43
+ """Run a full sync to a GitLab repository.
44
+
45
+ Exports Fossil commits to Git format and pushes to the GitLab remote.
46
+ Optionally syncs tickets as GitLab Issues and wiki pages.
47
+
48
+ Args:
49
+ include_tickets: If True, map Fossil tickets to GitLab Issues.
50
+ include_wiki: If True, export Fossil wiki pages to GitLab wiki.
51
+ """
52
+ raise NotImplementedError
53
+
54
+ def sync_commits(self) -> list[CommitMapping]:
55
+ """Sync Fossil commits to the Git remote.
56
+
57
+ Exports the Fossil timeline as Git commits and pushes to the
58
+ configured remote. Returns a mapping of Fossil checkin hashes
59
+ to Git commit SHAs.
60
+
61
+ Returns:
62
+ List of CommitMapping objects for each synced commit.
63
+ """
64
+ raise NotImplementedError
65
+
66
+ def sync_tickets(self) -> list[TicketMapping]:
67
+ """Sync Fossil tickets to the remote issue tracker.
68
+
69
+ Maps Fossil ticket fields to GitHub/GitLab issue fields. Creates
70
+ new issues for new tickets, updates existing ones.
71
+
72
+ Returns:
73
+ List of TicketMapping objects for each synced ticket.
74
+ """
75
+ raise NotImplementedError
76
+
77
+ def sync_wiki(self) -> list[WikiMapping]:
78
+ """Sync Fossil wiki pages to the remote.
79
+
80
+ Exports Fossil wiki pages as Markdown files. For GitHub, these go
81
+ into a docs/ directory. For GitLab, they go to the project wiki.
82
+
83
+ Returns:
84
+ List of WikiMapping objects for each synced page.
85
+ """
86
+ raise NotImplementedError
--- a/_old_fossilrepo/sync/mirror.py
+++ b/_old_fossilrepo/sync/mirror.py
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/_old_fossilrepo/sync/mirror.py
+++ b/_old_fossilrepo/sync/mirror.py
@@ -0,0 +1,86 @@
1 """Fossil-to-Git mirror — sync commits, tickets, and wiki to GitHub/GitLab."""
2
3 from pathlib import Path
4
5 from fossilrepo.sync.mappings import CommitMapping, TicketMapping, WikiMapping
6
7
8 class FossilMirror:
9 """Mirrors a Fossil repository to a Git remote (GitHub or GitLab).
10
11 Fossil is the source of truth. The Git remote is a downstream mirror
12 for ecosystem visibility. Syncs commits, optionally maps tickets to
13 issues and wiki pages to docs.
14 """
15
16 def __init__(self, fossil_path: Path, remote_url: str) -> None:
17 self.fossil_path = fossil_path
18 self.remote_url = remote_url
19
20 def sync_to_github(
21 self,
22 *,
23 include_tickets: bool = False,
24 include_wiki: bool = False,
25 ) -> None:
26 """Run a full sync to a GitHub repository.
27
28 Exports Fossil commits to Git format and pushes to the GitHub remote.
29 Optionally syncs tickets as GitHub Issues and wiki as repo docs.
30
31 Args:
32 include_tickets: If True, map Fossil tickets to GitHub Issues.
33 include_wiki: If True, export Fossil wiki pages to repo docs.
34 """
35 raise NotImplementedError
36
37 def sync_to_gitlab(
38 self,
39 *,
40 include_tickets: bool = False,
41 include_wiki: bool = False,
42 ) -> None:
43 """Run a full sync to a GitLab repository.
44
45 Exports Fossil commits to Git format and pushes to the GitLab remote.
46 Optionally syncs tickets as GitLab Issues and wiki pages.
47
48 Args:
49 include_tickets: If True, map Fossil tickets to GitLab Issues.
50 include_wiki: If True, export Fossil wiki pages to GitLab wiki.
51 """
52 raise NotImplementedError
53
54 def sync_commits(self) -> list[CommitMapping]:
55 """Sync Fossil commits to the Git remote.
56
57 Exports the Fossil timeline as Git commits and pushes to the
58 configured remote. Returns a mapping of Fossil checkin hashes
59 to Git commit SHAs.
60
61 Returns:
62 List of CommitMapping objects for each synced commit.
63 """
64 raise NotImplementedError
65
66 def sync_tickets(self) -> list[TicketMapping]:
67 """Sync Fossil tickets to the remote issue tracker.
68
69 Maps Fossil ticket fields to GitHub/GitLab issue fields. Creates
70 new issues for new tickets, updates existing ones.
71
72 Returns:
73 List of TicketMapping objects for each synced ticket.
74 """
75 raise NotImplementedError
76
77 def sync_wiki(self) -> list[WikiMapping]:
78 """Sync Fossil wiki pages to the remote.
79
80 Exports Fossil wiki pages as Markdown files. For GitHub, these go
81 into a docs/ directory. For GitLab, they go to the project wiki.
82
83 Returns:
84 List of WikiMapping objects for each synced page.
85 """
86 raise NotImplementedError

No diff available

--- a/auth1/apps.py
+++ b/auth1/apps.py
@@ -0,0 +1,7 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class Auth1Config(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "auth1"
7
+ verbose_name = "Authentication"
--- a/auth1/apps.py
+++ b/auth1/apps.py
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
--- a/auth1/apps.py
+++ b/auth1/apps.py
@@ -0,0 +1,7 @@
1 from django.apps import AppConfig
2
3
4 class Auth1Config(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "auth1"
7 verbose_name = "Authentication"
--- a/auth1/forms.py
+++ b/auth1/forms.py
@@ -0,0 +1,22 @@
1
+from django import forms
2
+from django.contrib.auth.forms import AuthenticationForm
3
+
4
+
5
+class LoginForm(AuthenticationForm):
6
+ username = forms.CharField(
7
+ widget=forms.TextInput(
8
+ attrs={
9
+ "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
10
+ "placeholder": "Username",
11
+ "autofocus": True,
12
+ }
13
+ )
14
+ )
15
+ password = forms.CharField(
16
+ widget=forms.PasswordInput(
17
+ attrs={
18
+ "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
19
+ "placeholder": "Password",
20
+ }
21
+ )
22
+ )
--- a/auth1/forms.py
+++ b/auth1/forms.py
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/auth1/forms.py
+++ b/auth1/forms.py
@@ -0,0 +1,22 @@
1 from django import forms
2 from django.contrib.auth.forms import AuthenticationForm
3
4
5 class LoginForm(AuthenticationForm):
6 username = forms.CharField(
7 widget=forms.TextInput(
8 attrs={
9 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
10 "placeholder": "Username",
11 "autofocus": True,
12 }
13 )
14 )
15 password = forms.CharField(
16 widget=forms.PasswordInput(
17 attrs={
18 "class": "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand",
19 "placeholder": "Password",
20 }
21 )
22 )

No diff available

--- a/auth1/tests.py
+++ b/auth1/tests.py
@@ -0,0 +1,46 @@
1
+import pytest
2
+from django.urls import reverse
3
+
4
+
5
+@pytest.mark.django_db
6
+class TestLogin:
7
+ def test_login_page_renders(self, client):
8
+ response = client.get(reverse("auth1:login"))
9
+ assert response.status_code == 200
10
+ assert b"Sign in" in response.content
11
+
12
+ def test_login_success_redirects_to_dashboard(self, client, admin_user):
13
+ response = client.post(reverse("auth1:login"), {"username": "admin", "password": "testpass123"})
14
+ assert response.status_code == 302
15
+ assert response.url == reverse("dashboard")
16
+
17
+ def test_login_failure_shows_error(self, client, admin_user):
18
+ response = client.post(reverse("auth1:login"), {"username": "admin", "password": "wrong"})
19
+ assert response.status_code == 200
20
+ assert b"Invalid username or password" in response.content
21
+
22
+ def test_login_redirect_when_already_authenticated(self, admin_client):
23
+ response = admin_client.get(reverse("auth1:login"))
24
+ assert response.status_code == 302
25
+
26
+ def test_login_with_next_param(self, client, admin_user):
27
+ response = client.post(reverse("auth1:login") + "?next=/items/", {"username": "admin", "password": "testpass123"})
28
+ assert response.status_code == 302
29
+ assert response.url == "/items/"
30
+
31
+
32
+@pytest.mark.django_db
33
+class TestLogout:
34
+ def test_logout_redirects_to_login(self, admin_client):
35
+ response = admin_client.post(reverse("auth1:logout"))
36
+ assert response.status_code == 302
37
+ assert reverse("auth1:login") in response.url
38
+
39
+ def test_logout_clears_session(self, admin_client):
40
+ admin_client.post(reverse("auth1:logout"))
41
+ response = admin_client.get(reverse("dashboard"))
42
+ assert response.status_code == 302 # redirected to login
43
+
44
+ def test_logout_rejects_get(self, admin_client):
45
+ response = admin_client.get(reverse("auth1:logout"))
46
+ assert response.status_code == 405
--- a/auth1/tests.py
+++ b/auth1/tests.py
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/auth1/tests.py
+++ b/auth1/tests.py
@@ -0,0 +1,46 @@
1 import pytest
2 from django.urls import reverse
3
4
5 @pytest.mark.django_db
6 class TestLogin:
7 def test_login_page_renders(self, client):
8 response = client.get(reverse("auth1:login"))
9 assert response.status_code == 200
10 assert b"Sign in" in response.content
11
12 def test_login_success_redirects_to_dashboard(self, client, admin_user):
13 response = client.post(reverse("auth1:login"), {"username": "admin", "password": "testpass123"})
14 assert response.status_code == 302
15 assert response.url == reverse("dashboard")
16
17 def test_login_failure_shows_error(self, client, admin_user):
18 response = client.post(reverse("auth1:login"), {"username": "admin", "password": "wrong"})
19 assert response.status_code == 200
20 assert b"Invalid username or password" in response.content
21
22 def test_login_redirect_when_already_authenticated(self, admin_client):
23 response = admin_client.get(reverse("auth1:login"))
24 assert response.status_code == 302
25
26 def test_login_with_next_param(self, client, admin_user):
27 response = client.post(reverse("auth1:login") + "?next=/items/", {"username": "admin", "password": "testpass123"})
28 assert response.status_code == 302
29 assert response.url == "/items/"
30
31
32 @pytest.mark.django_db
33 class TestLogout:
34 def test_logout_redirects_to_login(self, admin_client):
35 response = admin_client.post(reverse("auth1:logout"))
36 assert response.status_code == 302
37 assert reverse("auth1:login") in response.url
38
39 def test_logout_clears_session(self, admin_client):
40 admin_client.post(reverse("auth1:logout"))
41 response = admin_client.get(reverse("dashboard"))
42 assert response.status_code == 302 # redirected to login
43
44 def test_logout_rejects_get(self, admin_client):
45 response = admin_client.get(reverse("auth1:logout"))
46 assert response.status_code == 405
--- a/auth1/urls.py
+++ b/auth1/urls.py
@@ -0,0 +1,10 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "auth1"
6
+
7
+urlpatterns = [
8
+ path("login/", views.login_view, name="login"),
9
+ path("logout/", views.logout_view, name="logout"),
10
+]
--- a/auth1/urls.py
+++ b/auth1/urls.py
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
--- a/auth1/urls.py
+++ b/auth1/urls.py
@@ -0,0 +1,10 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "auth1"
6
7 urlpatterns = [
8 path("login/", views.login_view, name="login"),
9 path("logout/", views.logout_view, name="logout"),
10 ]
--- a/auth1/views.py
+++ b/auth1/views.py
@@ -0,0 +1,29 @@
1
+from django.contribessages
2
+fromshortcuts import redirect, render
3
+from django.views.decorators.http import require_POST
4
+from django_ratelimit.decorators import ratelimit
5
+
6
+from .forms import LoginForm
7
+
8
+
9
+@ratelimit(key="ip", rate="10/m", block=True)
10
+def login_view(request):
11
+ if request.user.is_authenticated:
12
+ return redirect("dashboard")
13
+
14
+ if request.method == "POST":
15
+ form = LoginForm(request, data=request.POST)
16
+ if form.is_valid():
17
+ login(request, form.get_user())
18
+ next_url = request.GET.get("next", "dashboard")
19
+ return redirect(next_url)
20
+ else:
21
+ form = LoginForm()
22
+
23
+ return render(request, "auth1/login.html", {"form": form})
24
+
25
+
26
+@require_POST
27
+def logout_view(request):
28
+ logout(request)
29
+ retu
--- a/auth1/views.py
+++ b/auth1/views.py
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/auth1/views.py
+++ b/auth1/views.py
@@ -0,0 +1,29 @@
1 from django.contribessages
2 fromshortcuts import redirect, render
3 from django.views.decorators.http import require_POST
4 from django_ratelimit.decorators import ratelimit
5
6 from .forms import LoginForm
7
8
9 @ratelimit(key="ip", rate="10/m", block=True)
10 def login_view(request):
11 if request.user.is_authenticated:
12 return redirect("dashboard")
13
14 if request.method == "POST":
15 form = LoginForm(request, data=request.POST)
16 if form.is_valid():
17 login(request, form.get_user())
18 next_url = request.GET.get("next", "dashboard")
19 return redirect(next_url)
20 else:
21 form = LoginForm()
22
23 return render(request, "auth1/login.html", {"form": form})
24
25
26 @require_POST
27 def logout_view(request):
28 logout(request)
29 retu
--- a/boilerworks.yaml
+++ b/boilerworks.yaml
@@ -0,0 +1,57 @@
1
+# boilerworks.yaml — fossilrepo project manifest
2
+#
3
+# Run `boilerworks init` to generate the project from this file.
4
+
5
+# ── Required ──────────────────────────────────────────────────────────────────
6
+
7
+project: fossilrepo
8
+family: django-htmx
9
+size: full
10
+
11
+# ── Topology ──────────────────────────────────────────────────────────────────
12
+
13
+topology: standard
14
+
15
+# ── Cloud ─────────────────────────────────────────────────────────────────────
16
+
17
+cloud: aws
18
+region: us-east-1
19
+
20
+# ── Domain ────────────────────────────────────────────────────────────────────
21
+
22
+domain: fossilrepo.dev
23
+
24
+# ── Optional add-ons ──────────────────────────────────────────────────────────
25
+
26
+mobile: false
27
+web_presence: false
28
+
29
+# ── Compliance ────────────────────────────────────────────────────────────────
30
+
31
+compliance: []
32
+
33
+# ── Services ──────────────────────────────────────────────────────────────────
34
+
35
+services:
36
+ email: ses
37
+ storage: s3
38
+ search: null
39
+ cache: redis
40
+
41
+# ── Data ──────────────────────────────────────────────────────────────────────
42
+
43
+data:
44
+ database: postgres
45
+ migrations: true
46
+ seed_data: true
47
+
48
+# ── Testing ───────────────────────────────────────────────────────────────────
49
+
50
+testing:
51
+ e2e: null
52
+ unit: true
53
+ integration: true
54
+
55
+# ── Template versions (auto-managed, do not edit manually) ────────────────────
56
+
57
+template_versions: {}
--- a/boilerworks.yaml
+++ b/boilerworks.yaml
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/boilerworks.yaml
+++ b/boilerworks.yaml
@@ -0,0 +1,57 @@
1 # boilerworks.yaml — fossilrepo project manifest
2 #
3 # Run `boilerworks init` to generate the project from this file.
4
5 # ── Required ──────────────────────────────────────────────────────────────────
6
7 project: fossilrepo
8 family: django-htmx
9 size: full
10
11 # ── Topology ──────────────────────────────────────────────────────────────────
12
13 topology: standard
14
15 # ── Cloud ─────────────────────────────────────────────────────────────────────
16
17 cloud: aws
18 region: us-east-1
19
20 # ── Domain ────────────────────────────────────────────────────────────────────
21
22 domain: fossilrepo.dev
23
24 # ── Optional add-ons ──────────────────────────────────────────────────────────
25
26 mobile: false
27 web_presence: false
28
29 # ── Compliance ────────────────────────────────────────────────────────────────
30
31 compliance: []
32
33 # ── Services ──────────────────────────────────────────────────────────────────
34
35 services:
36 email: ses
37 storage: s3
38 search: null
39 cache: redis
40
41 # ── Data ──────────────────────────────────────────────────────────────────────
42
43 data:
44 database: postgres
45 migrations: true
46 seed_data: true
47
48 # ── Testing ───────────────────────────────────────────────────────────────────
49
50 testing:
51 e2e: null
52 unit: true
53 integration: true
54
55 # ── Template versions (auto-managed, do not edit manually) ────────────────────
56
57 template_versions: {}
+364
--- a/bootstrap.md
+++ b/bootstrap.md
@@ -0,0 +1,364 @@
1
+# fossilrepo -- bootstrap
2
+
3
+This is the primary conventions document. All agent shims (`CLAUDE.md`, `AGENTS.md`) point here.
4
+
5
+An agent given this document and a business requirement should be able to generate correct, idiomatic code without exploring the codebase.
6
+
7
+---
8
+
9
+## What is fossilrepo
10
+
11
+Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups -- all powered by Fossil SCM.
12
+
13
+Think GitLab Omnibus, but for Fossil.
14
+
15
+---
16
+
17
+## Why Fossil
18
+
19
+A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable -- hand the file to someone and they have everything.
20
+
21
+For teams running CI agents or automation:
22
+- Agents commit, file tickets, and update the wiki through one CLI and one protocol
23
+- No API rate limits when many agents are pushing simultaneously
24
+- The `.fossil` file IS the project artifact -- a self-contained archive
25
+- Luth1am replicates it to S3 continuously -- backup and point-in-time recovery for free
26
+
27
+Fitems/ # Exampl-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
28
+
29
+---
30
+
31
+## What fossilrepo Does
32
+
33
+fossilrepo packages everything needed to run a production Fossil server into one installable unit:
34
+
35
+- **Fossil server** -- serves all repos fromuth1),ssion-based authenticatioSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
36
+- **Litestream** -- continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
37
+- **CLI** -- repo lifecycle management (create, list, delete) and sync tooling
38
+- **Sync bridge** -- mirror Fossil repos to GitHub/GitLab as downstream read-only copies
39
+
40
+New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
41
+
42
+---
43
+
44
+## Server Stack
45
+
46
+```
47
+Caddy (SSL termination, routing, subdomain per repo)
48
+ +-- fossil server --repolist /data/repos/
49
+ +-- /data/repos/
50
+ itemsil
51
+ +-- ...
52
+
53
+Litestream -> S3/MinIO (continuous replication, point-in-time recovery)
54
+```
55
+
56
+One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
57
+
58
+### Sync Bridge
59
+
60
+Mirrors Fossil to GitHub/GitLab as a downstreuth1` | Session-based authentication: login/logout views with rate limiting |
61
+| `organization` | Organization + OrganizationMember models |
62
+| `items` | Example CRUD domain demonstrating all patterns (reference only -- new Fossil-specific apps will replace this as the primary domain) |
63
+| `testdata` | `seed` management command for development data |
64
+
65
+---
66
+
67
+## Conventions
68
+
69
+### Models
70
+
71
+All business models inherit from one of:
72
+
73
+**`Tracking`** (abstract) -- audit trails:
74
+```python
75
+from core.models import Tracking
76
+
77
+class Invoice(Tracking):
78
+ amount = models.DecimalField(...)
79
+```
80
+Provides: `version` (auto-increments), `created_at/by`, `updated_at/by`, `deleted_at/by`, `history` (simple_history).
81
+
82
+**`BaseCoreModel(Tracking)`** (abstract) -- named entities:
83
+```python
84
+from core.models import BaseCoreModel
85
+
86
+class ItemaseCoreModel
87
+
88
+class Pprice = models.DecimalField(...)
89
+```
90
+ models.CharField(...)
91
+```
92
+Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`.
93
+
94
+**Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`.
95
+
96
+**ActiveManager:** Use `objects` (excludes deleted) for queries, `all_objects` for admin.
97
+
98
+---
99
+
100
+### Views (HTMX Pattern)
101
+
102
+Views return full pages for normal requests, HTMX partials for `HX-Request`:
103
+
104
+```item_list(request):
105
+ P.ITEM_VIEW.check(request.user)
106
+ items = Item.objects.all()
107
+
108
+ if request.headers.get("HX-Request"):
109
+ return render(request, "items/partials/item_tableers.get("HX-Request"):
110
+ items/itemhtml", {"projects": projects})
111
+```
112
+
113
+**URL patterns** follow CRUD convention:
114
+```python
115
+urlpatterns = [
116
+ path("", views.project_list, name="list"),
117
+ path("create/", views.project_create, name="create"),
118
+ path("<slug:slug>/", views.project_detail, name="detail"),
119
+ path("<slug:slug>/edit/", views.project_update, name="update"),
120
+ path("<slug:slug>/delete/", views.project_delete, name="delete"),
121
+]
122
+```
123
+
124
+---
125
+
126
+### Permissions
127
+
128
+Group-based. Never user-based. Checked in every view.
129
+
130
+```python
131
+from core.permissions import P
132
+
133
+P.PROJECT_VIEW.check(request.user) # raises PermissionDenied if denied
134
+P.PROJECT_ADD.check(request.user, raise_error=False) # returns False instead
135
+```
136
+
137
+Template guards:
138
+```html
139
+{% if perms.projects.view_project %}
140
+ <a href="{% url 'projects:list' %}">Projects</a>
141
+{% endif %}
142
+```
143
+
144
+---
145
+
146
+### Admin
147
+
148
+All admin classes inherit `BaseCoreAdmin`:
149
+```python
150
+from core.admin import BaseCoreAdmin
151
+
152
+@admin.register(Project)
153
+class ProjectAdmin(BaseCoreAdmin):
154
+ list_display = ("name", "slug", "visibility", "created_at")
155
+ search_fields = ("name", "slug")
156
+```
157
+
158
+`BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export.
159
+
160
+---
161
+
162
+### Templates
163
+
164
+- `base.html` -- layout with HTMX, Alpine.js, Tailwind CSS, CSRF injection, messages
165
+- `includes/nav.html` -- navigation bar with permission guards
166
+- `{app}/partials/*.html` -- HTMX partial templates (no `{% extends %}`)
167
+- CSRF token sent with all HTMX requests via `htmx:configRequest` event
168
+
169
+Alpine.js patterns for client-side interactivity:
170
+```html
171
+<div x-data="{ open: false }">
172
+ <button @click="open = !open">Toggle</button>
173
+ <div x-show="open" x-transition>Content</div>
174
+</div>
175
+```
176
+
177
+---
178
+
179
+### Tests
180
+
181
+pytest + real Postgres. Assert against database state.
182
+
183
+```python
184
+@pytest.mark.django_db
185
+class TestProjectCreate:
186
+ def test_create_saves_project(self, admin_client, admin_user, org):
187
+ response = admin_client.post(reverse("projects:create"), {
188
+ "name": "New App", "visibility": "private", ...
189
+ })
190
+ assert response.status_code == 302
191
+ project = Project.objects.get(name="New App")
192
+ assert project.created_by == admin_user
193
+
194
+ def test_create_denied_for_viewer(self, viewer_client):
195
+ response = viewer_client.get(reverse("projects:create"))
196
+ assert response.status_code == 403
197
+```
198
+
199
+Both allowed AND denied permission cases for every endpoint.
200
+
201
+---
202
+
203
+### Code Style
204
+
205
+| Tool | Config |
206
+|------|--------|
207
+| Ruff (lint + format) | `pyproject.toml`, line length 140 |
208
+| Import sorting | Ruff isort rules |
209
+| Python version | 3.12+ |
210
+
211
+Run `ruff check .` and `ruff format --check .` before committing.
212
+
213
+---
214
+
215
+## Adding a New App
216
+
217
+```bash
218
+# 1. Create the app
219
+python manage.py startapp myapp
220
+
221
+# 2. Add to INSTALLED_APPS in config/settings.py
222
+
223
+# 3. Create models inheriting Tracking or BaseCoreModel
224
+
225
+# 4. Create migrations
226
+python manage.py makemigrations
227
+
228
+# 5. Create admin (inherit BaseCoreAdmin)
229
+
230
+# 6. Create views with @login_required + P.PERMISSION.check()
231
+
232
+# 7. Create URL patterns (list, detail, create, update, delete)
233
+
234
+# 8. Create templates (full page + HTMX partials)
235
+
236
+# 9. Add permission entries to core/permissions.py P enum
237
+
238
+# 10. Write tests (allowed + denied)
239
+python -m pytest --cov -v
240
+```
241
+
242
+---
243
+
244
+## Ports (local Docker)
245
+
246
+| Service | URL |
247
+|---|---|
248
+| Django | http://localhost:8000 |
249
+| Django Admin | http://localhost:8000/admin/ |
250
+| Health | http://localhost:8000/health/ |
251
+| Mailpit | http://localhost:8025 |
252
+| Postgres | localhost:5432 |
253
+| Redis | localhost:6379 |
254
+
255
+---
256
+
257
+## Common Commands
258
+
259
+```bash
260
+make up # Start the stack
261
+make build # Build and start
262
+make down # Stop the stack
263
+make migrate # Run migrations
264
+make migrations # Create migrations
265
+make seed # Load dev fixtures
266
+make test # Run tests with coverage
267
+make lint # Run Ruff check + format
268
+make superuser # Create Django superuser
269
+make shell # Shell into container
270
+make logs # Tail Django logs
271
+```
272
+
273
+---
274
+
275
+## Platform Vision (fossilrepos.com)
276
+
277
+GitLab model:
278
+- **Self-hosted** -- open source, run it yourself. fossilrepo is the tool.
279
+- **Managed** -- fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
280
+
281
+The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite -- Fossil already does the hard parts. The value is the hosting and UX polish.
282
+
283
+Not being built yet -- get the self-hosted tool right first.
284
+
285
+---
286
+
287
+## License
288
+
289
+MIT.
290
+item_list, name="list"),item_create, name="create"),
291
+ item_detail, name="detail"),
292
+ path(itemitem_delete, name="delete"),
293
+]
294
+```
295
+
296
+---
297
+
298
+### Permissions
299
+
300
+Group-based. Never user-based. Checked in every view.
301
+
302
+```python
303
+from core.permissions import P
304
+
305
+P.ITEM_VIEW.check(request.user) # raises PeITEMnDenied if denied
306
+P.PROJECT_ADD.check(request.user, raise_error=False) # returns Faitems.view_item %}
307
+ <a href="{% url 'items:list' %}">Item'projects:list' %}">Projects</a>
308
+{% endif %}
309
+```
310
+
311
+---
312
+
313
+### Admin
314
+
315
+All admin classes inherit `BaseCoreAdmin`:
316
+```python
317
+from core.admin import BaseItem)
318
+class Itemdel
319
+
320
+class Project(BaseCoreModelprice...)
321
+```
322
+Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`.
323
+
324
+**Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`.
325
+
326
+**ActiveManager:** Use `objects` (excludes deleted) for queries, `all_objects` for admin.
327
+
328
+---
329
+
330
+### Views (HTMX Pattern)
331
+
332
+Views return full pages for normal requests, HTMX partials for `HX-Request`:
333
+
334
+```python
335
+@login_required
336
+def project_list(request):
337
+ P.PROJECT_VIEW.check(request.user)
338
+ projects = Project.objects.all()
339
+
340
+ if request.headers.get("HX-Request"):
341
+ return render(request, "projects/partials/project_table.html", {"projects": projects})
342
+
343
+ return render(request, "projects/project_list.html", {"projects": projects})
344
+```
345
+
346
+**URL patterns** follow CRUD convention:
347
+```python
348
+urlpatterns = [
349
+Itemhon
350
+urlpatterns = [
351
+ path("", vitem path("create/", views.projec P
352
+
353
+P.PROJECT_VIEW.che path("<slug:slug>/", vieitemUDE.md`, `AGENTS.md`) point here.
354
+
355
+# fossilrepo -- bootstrject_update, name="update"),
356
+ path("<slug:slug>/delete/", views.project_delete, name="delete"),
357
+]
358
+```
359
+
360
+---
361
+
362
+### Permissions
363
+
364
+Group-based. Never user-based.
--- a/bootstrap.md
+++ b/bootstrap.md
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/bootstrap.md
+++ b/bootstrap.md
@@ -0,0 +1,364 @@
1 # fossilrepo -- bootstrap
2
3 This is the primary conventions document. All agent shims (`CLAUDE.md`, `AGENTS.md`) point here.
4
5 An agent given this document and a business requirement should be able to generate correct, idiomatic code without exploring the codebase.
6
7 ---
8
9 ## What is fossilrepo
10
11 Omnibus-style installer for a self-hosted Fossil forge. One command gets you a full-stack code hosting platform: VCS, issues, wiki, timeline, web UI, SSL, and continuous backups -- all powered by Fossil SCM.
12
13 Think GitLab Omnibus, but for Fossil.
14
15 ---
16
17 ## Why Fossil
18
19 A Fossil repo is a single SQLite file. It contains the full VCS history, issue tracker, wiki, forum, and timeline. No external services. No rate limits. Portable -- hand the file to someone and they have everything.
20
21 For teams running CI agents or automation:
22 - Agents commit, file tickets, and update the wiki through one CLI and one protocol
23 - No API rate limits when many agents are pushing simultaneously
24 - The `.fossil` file IS the project artifact -- a self-contained archive
25 - Luth1am replicates it to S3 continuously -- backup and point-in-time recovery for free
26
27 Fitems/ # Exampl-in web UI (skinnable), autosync, peer-to-peer sync, and unversioned content storage (like Git LFS but built-in).
28
29 ---
30
31 ## What fossilrepo Does
32
33 fossilrepo packages everything needed to run a production Fossil server into one installable unit:
34
35 - **Fossil server** -- serves all repos fromuth1),ssion-based authenticatioSL termination, subdomain-per-repo routing (`reponame.your-domain.com`)
36 - **Litestream** -- continuous SQLite replication to S3/MinIO (backup + point-in-time recovery)
37 - **CLI** -- repo lifecycle management (create, list, delete) and sync tooling
38 - **Sync bridge** -- mirror Fossil repos to GitHub/GitLab as downstream read-only copies
39
40 New project = `fossil init`. No restart, no config change. Litestream picks it up automatically.
41
42 ---
43
44 ## Server Stack
45
46 ```
47 Caddy (SSL termination, routing, subdomain per repo)
48 +-- fossil server --repolist /data/repos/
49 +-- /data/repos/
50 itemsil
51 +-- ...
52
53 Litestream -> S3/MinIO (continuous replication, point-in-time recovery)
54 ```
55
56 One binary serves all repos. The whole platform is: repo creation + subdomain provisioning + Litestream config.
57
58 ### Sync Bridge
59
60 Mirrors Fossil to GitHub/GitLab as a downstreuth1` | Session-based authentication: login/logout views with rate limiting |
61 | `organization` | Organization + OrganizationMember models |
62 | `items` | Example CRUD domain demonstrating all patterns (reference only -- new Fossil-specific apps will replace this as the primary domain) |
63 | `testdata` | `seed` management command for development data |
64
65 ---
66
67 ## Conventions
68
69 ### Models
70
71 All business models inherit from one of:
72
73 **`Tracking`** (abstract) -- audit trails:
74 ```python
75 from core.models import Tracking
76
77 class Invoice(Tracking):
78 amount = models.DecimalField(...)
79 ```
80 Provides: `version` (auto-increments), `created_at/by`, `updated_at/by`, `deleted_at/by`, `history` (simple_history).
81
82 **`BaseCoreModel(Tracking)`** (abstract) -- named entities:
83 ```python
84 from core.models import BaseCoreModel
85
86 class ItemaseCoreModel
87
88 class Pprice = models.DecimalField(...)
89 ```
90 models.CharField(...)
91 ```
92 Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`.
93
94 **Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`.
95
96 **ActiveManager:** Use `objects` (excludes deleted) for queries, `all_objects` for admin.
97
98 ---
99
100 ### Views (HTMX Pattern)
101
102 Views return full pages for normal requests, HTMX partials for `HX-Request`:
103
104 ```item_list(request):
105 P.ITEM_VIEW.check(request.user)
106 items = Item.objects.all()
107
108 if request.headers.get("HX-Request"):
109 return render(request, "items/partials/item_tableers.get("HX-Request"):
110 items/itemhtml", {"projects": projects})
111 ```
112
113 **URL patterns** follow CRUD convention:
114 ```python
115 urlpatterns = [
116 path("", views.project_list, name="list"),
117 path("create/", views.project_create, name="create"),
118 path("<slug:slug>/", views.project_detail, name="detail"),
119 path("<slug:slug>/edit/", views.project_update, name="update"),
120 path("<slug:slug>/delete/", views.project_delete, name="delete"),
121 ]
122 ```
123
124 ---
125
126 ### Permissions
127
128 Group-based. Never user-based. Checked in every view.
129
130 ```python
131 from core.permissions import P
132
133 P.PROJECT_VIEW.check(request.user) # raises PermissionDenied if denied
134 P.PROJECT_ADD.check(request.user, raise_error=False) # returns False instead
135 ```
136
137 Template guards:
138 ```html
139 {% if perms.projects.view_project %}
140 <a href="{% url 'projects:list' %}">Projects</a>
141 {% endif %}
142 ```
143
144 ---
145
146 ### Admin
147
148 All admin classes inherit `BaseCoreAdmin`:
149 ```python
150 from core.admin import BaseCoreAdmin
151
152 @admin.register(Project)
153 class ProjectAdmin(BaseCoreAdmin):
154 list_display = ("name", "slug", "visibility", "created_at")
155 search_fields = ("name", "slug")
156 ```
157
158 `BaseCoreAdmin` provides: audit fields as readonly, `created_by`/`updated_by` auto-set, import/export.
159
160 ---
161
162 ### Templates
163
164 - `base.html` -- layout with HTMX, Alpine.js, Tailwind CSS, CSRF injection, messages
165 - `includes/nav.html` -- navigation bar with permission guards
166 - `{app}/partials/*.html` -- HTMX partial templates (no `{% extends %}`)
167 - CSRF token sent with all HTMX requests via `htmx:configRequest` event
168
169 Alpine.js patterns for client-side interactivity:
170 ```html
171 <div x-data="{ open: false }">
172 <button @click="open = !open">Toggle</button>
173 <div x-show="open" x-transition>Content</div>
174 </div>
175 ```
176
177 ---
178
179 ### Tests
180
181 pytest + real Postgres. Assert against database state.
182
183 ```python
184 @pytest.mark.django_db
185 class TestProjectCreate:
186 def test_create_saves_project(self, admin_client, admin_user, org):
187 response = admin_client.post(reverse("projects:create"), {
188 "name": "New App", "visibility": "private", ...
189 })
190 assert response.status_code == 302
191 project = Project.objects.get(name="New App")
192 assert project.created_by == admin_user
193
194 def test_create_denied_for_viewer(self, viewer_client):
195 response = viewer_client.get(reverse("projects:create"))
196 assert response.status_code == 403
197 ```
198
199 Both allowed AND denied permission cases for every endpoint.
200
201 ---
202
203 ### Code Style
204
205 | Tool | Config |
206 |------|--------|
207 | Ruff (lint + format) | `pyproject.toml`, line length 140 |
208 | Import sorting | Ruff isort rules |
209 | Python version | 3.12+ |
210
211 Run `ruff check .` and `ruff format --check .` before committing.
212
213 ---
214
215 ## Adding a New App
216
217 ```bash
218 # 1. Create the app
219 python manage.py startapp myapp
220
221 # 2. Add to INSTALLED_APPS in config/settings.py
222
223 # 3. Create models inheriting Tracking or BaseCoreModel
224
225 # 4. Create migrations
226 python manage.py makemigrations
227
228 # 5. Create admin (inherit BaseCoreAdmin)
229
230 # 6. Create views with @login_required + P.PERMISSION.check()
231
232 # 7. Create URL patterns (list, detail, create, update, delete)
233
234 # 8. Create templates (full page + HTMX partials)
235
236 # 9. Add permission entries to core/permissions.py P enum
237
238 # 10. Write tests (allowed + denied)
239 python -m pytest --cov -v
240 ```
241
242 ---
243
244 ## Ports (local Docker)
245
246 | Service | URL |
247 |---|---|
248 | Django | http://localhost:8000 |
249 | Django Admin | http://localhost:8000/admin/ |
250 | Health | http://localhost:8000/health/ |
251 | Mailpit | http://localhost:8025 |
252 | Postgres | localhost:5432 |
253 | Redis | localhost:6379 |
254
255 ---
256
257 ## Common Commands
258
259 ```bash
260 make up # Start the stack
261 make build # Build and start
262 make down # Stop the stack
263 make migrate # Run migrations
264 make migrations # Create migrations
265 make seed # Load dev fixtures
266 make test # Run tests with coverage
267 make lint # Run Ruff check + format
268 make superuser # Create Django superuser
269 make shell # Shell into container
270 make logs # Tail Django logs
271 ```
272
273 ---
274
275 ## Platform Vision (fossilrepos.com)
276
277 GitLab model:
278 - **Self-hosted** -- open source, run it yourself. fossilrepo is the tool.
279 - **Managed** -- fossilrepos.com, hosted for you. Subdomain per repo, modern UI, billing.
280
281 The platform is Fossil's built-in web UI with a modern skin + thin API wrapper + authentication. Not a rewrite -- Fossil already does the hard parts. The value is the hosting and UX polish.
282
283 Not being built yet -- get the self-hosted tool right first.
284
285 ---
286
287 ## License
288
289 MIT.
290 item_list, name="list"),item_create, name="create"),
291 item_detail, name="detail"),
292 path(itemitem_delete, name="delete"),
293 ]
294 ```
295
296 ---
297
298 ### Permissions
299
300 Group-based. Never user-based. Checked in every view.
301
302 ```python
303 from core.permissions import P
304
305 P.ITEM_VIEW.check(request.user) # raises PeITEMnDenied if denied
306 P.PROJECT_ADD.check(request.user, raise_error=False) # returns Faitems.view_item %}
307 <a href="{% url 'items:list' %}">Item'projects:list' %}">Projects</a>
308 {% endif %}
309 ```
310
311 ---
312
313 ### Admin
314
315 All admin classes inherit `BaseCoreAdmin`:
316 ```python
317 from core.admin import BaseItem)
318 class Itemdel
319
320 class Project(BaseCoreModelprice...)
321 ```
322 Adds: `guid` (UUID), `name`, `slug` (auto-generated, unique), `description`.
323
324 **Soft deletes:** call `obj.soft_delete(user=request.user)`, never `.delete()`.
325
326 **ActiveManager:** Use `objects` (excludes deleted) for queries, `all_objects` for admin.
327
328 ---
329
330 ### Views (HTMX Pattern)
331
332 Views return full pages for normal requests, HTMX partials for `HX-Request`:
333
334 ```python
335 @login_required
336 def project_list(request):
337 P.PROJECT_VIEW.check(request.user)
338 projects = Project.objects.all()
339
340 if request.headers.get("HX-Request"):
341 return render(request, "projects/partials/project_table.html", {"projects": projects})
342
343 return render(request, "projects/project_list.html", {"projects": projects})
344 ```
345
346 **URL patterns** follow CRUD convention:
347 ```python
348 urlpatterns = [
349 Itemhon
350 urlpatterns = [
351 path("", vitem path("create/", views.projec P
352
353 P.PROJECT_VIEW.che path("<slug:slug>/", vieitemUDE.md`, `AGENTS.md`) point here.
354
355 # fossilrepo -- bootstrject_update, name="update"),
356 path("<slug:slug>/delete/", views.project_delete, name="delete"),
357 ]
358 ```
359
360 ---
361
362 ### Permissions
363
364 Group-based. Never user-based.

No diff available

--- a/config/celery.py
+++ b/config/celery.py
@@ -0,0 +1,9 @@
1
+import os
2
+
3
+from celery import Celery
4
+
5
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
6
+
7
+app = Celery("fossilrepo")
8
+app.config_from_object("django.conf:settings", namespace="CELERY")
9
+app.autodiscover_tasks()
--- a/config/celery.py
+++ b/config/celery.py
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
--- a/config/celery.py
+++ b/config/celery.py
@@ -0,0 +1,9 @@
1 import os
2
3 from celery import Celery
4
5 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
6
7 app = Celery("fossilrepo")
8 app.config_from_object("django.conf:settings", namespace="CELERY")
9 app.autodiscover_tasks()
--- a/config/settings.py
+++ b/config/settings.py
@@ -0,0 +1,180 @@
1
+import logging
2
+import os
3
+from pathlib import Path
4
+
5
+from django.core.exceptions import ImproperlyConfigured
6
+
7
+logger = logging.getLogger(__name__)
8
+
9
+VERSION = "0.1.0"
10
+
11
+BASE_DIR = Path(__file__).resolve().parent.parent
12
+
13
+
14
+def env_str(name: str, default: str | None = None) -> str | None:
15
+ return os.getenv(name, default)
16
+
17
+
18
+def env_bool(name: str, default: bool = False) -> bool:
19
+ return os.getenv(name, str(default)).lower() in ("true", "1", "yes")
20
+
21
+
22
+def env_int(name: str, default: int = 0) -> int:
23
+ return int(os.getenv(name, str(default)))
24
+
25
+
26
+# --- Security ---
27
+
28
+SECRET_KEY = env_str("DJANGO_SECRET_KEY", "change-me-in-production")
29
+DEBUG = env_bool("DJANGO_DEBUG", False)
30
+ALLOWED_HOSTS = [h.strip() for h in env_str("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")]
31
+
32
+if not DEBUG and SECRET_KEY == "change-me-in-production":
33
+ raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set to a unique, unpredictable value when DEBUG is False.")
34
+
35
+# --- Application ---
36
+
37
+ROOT_URLCONF = "config.urls"
38
+WSGI_APPLICATION = "config.wsgi.application"
39
+DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
40
+LOGIN_URL = "/auth/login/"
41
+LOGIN_REDIRECT_URL = "/"
42
+LOGOUT_REDIRECT_URL = "/auth/login/"
43
+
44
+INSTALLED_APPS = [
45
+ "django.contrib.admin",
46
+ "django.contrib.auth",
47
+ "django.contrib.contenttypes",
48
+ "django.contrib.sessions",
49
+ "django.contrib.messages",
50
+ "django.contrib.staticfiles",
51
+ "django.contrib.humanize",
52
+ # Third-party
53
+ "import_export",
54
+ "simple_history",
55
+ "django_celery_results",
56
+ "django_celery_beat",
57
+ "corsheaders",
58
+ "constance",
59
+ "constance.backends.database",
60
+ # Project apps
61
+ "core",
62
+ "auth1",
63
+ "organization",
64
+ "items",
65
+ "projects",
66
+ "pages",
67
+ "fossil",
68
+ "testdata",
69
+]
70
+
71
+MIDDLEWARE = [
72
+ "corsheaders.middleware.CorsMiddleware",
73
+ "django.middleware.security.SecurityMiddleware",
74
+ "whitenoise.middleware.WhiteNoiseMiddleware",
75
+ "django.contrib.sessions.middleware.SessionMiddleware",
76
+ "django.middleware.common.CommonMiddleware",
77
+ "django.middleware.csrf.CsrfViewMiddleware",
78
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
79
+ "django.contrib.messages.middleware.MessageMiddleware",
80
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
81
+ "simple_history.middleware.HistoryRequestMiddleware",
82
+ "core.middleware.current_user.CurrentUserMiddleware",
83
+]
84
+
85
+TEMPLATES = [
86
+ {
87
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
88
+ "DIRS": [BASE_DIR / "templates"],
89
+ "APP_DIRS": True,
90
+ "OPTIONS": {
91
+ "context_processors": [
92
+ "django.template.context_processors.debug",
93
+ "django.template.context_processors.request",
94
+ "django.contrib.auth.context_processors.auth",
95
+ "django.contrib.messages.context_processors.messages",
96
+ "core.context_processors.sidebar",
97
+ ],
98
+ },
99
+ },
100
+]
101
+
102
+# --- Database ---
103
+
104
+DATABASES = {
105
+ "default": {
106
+ "ENGINE": "django.db.backends.postgresql",
107
+ "NAME": env_str("POSTGRES_DB", "fossilrepo"),
108
+ "USER": env_str("POSTGRES_USER", "dbadmin"),
109
+ "PASSWORD": env_str("POSTGRES_PASSWORD", "Password123"),
110
+ "HOST": env_str("POSTGRES_HOST", "localhost"),
111
+ "PORT": env_str("POSTGRES_PORT", "5432"),
112
+ }
113
+}
114
+
115
+# --- Cache ---
116
+
117
+REDIS_URL = env_str("REDIS_URL", "redis://localhost:6379/1")
118
+
119
+CACHES = {
120
+ "default": {
121
+ "BACKEND": "django.core.cache.backends.redis.RedisCache",
122
+ "LOCATION": REDIS_URL,
123
+ }
124
+}
125
+
126
+# --- Auth ---
127
+
128
+AUTH_PASSWORD_VALIDATORS = [
129
+ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
130
+ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
131
+ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
132
+ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
133
+]
134
+
135
+AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
136
+SESSION_ENGINE = "django.contrib.sessions.backends.db"
137
+SESSION_COOKIE_HTTPONLY = True
138
+SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days
139
+CSRF_COOKIE_HTTPONLY = True
140
+
141
+if not DEBUG:
142
+ True
143
+ CSRF_COOKIE_SECURE = True
144
+ SECURE_SSL_REDIRECT = Truept ImproperlyConfigured
145
+
146
+logger = logging.getLogger(__name__)
147
+
148
+VERSION = "0.1.0"
149
+
150
+BASE_DIR = Path(__file__).resolve().parent.parent
151
+
152
+
153
+def env_str(name: str, default: str | None = None) -> str | None:
154
+ return os.getenv(name, default)
155
+
156
+
157
+def env_bool(name: str, default: bool = False) -> bool:
158
+ return os.getenv(name, str(default)).lower() in ("true", "1", "yes")
159
+
160
+
161
+def env_int(name: str, default: int = 0) -> int:
162
+ return int(os.getenv(name, str(default)))
163
+
164
+
165
+# --- Security ---
166
+
167
+SECRET_KEY = env_str("DJANGO_SECRET_KEY", "change-me-in-production")
168
+DEBUG = env_bool("DJANGO_DEBUG", False)
169
+ALLOWED_HOSTS = [h.strip() for h in env_str("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")]
170
+
171
+if not DEBUG and SECRET_KEY == "change-me-in-production":
172
+ raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set to a unique, unpredictable value when DEBUG is False.")
173
+
174
+# --- Application ---
175
+
176
+ROOT_URLCONF = "config.urls"
177
+WSGI_APPLICATION = "config.wsgi.application"
178
+DEFAULT_AUTO_FIELDges",
179
+ "fossil",
180
+ "testd
--- a/config/settings.py
+++ b/config/settings.py
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/config/settings.py
+++ b/config/settings.py
@@ -0,0 +1,180 @@
1 import logging
2 import os
3 from pathlib import Path
4
5 from django.core.exceptions import ImproperlyConfigured
6
7 logger = logging.getLogger(__name__)
8
9 VERSION = "0.1.0"
10
11 BASE_DIR = Path(__file__).resolve().parent.parent
12
13
14 def env_str(name: str, default: str | None = None) -> str | None:
15 return os.getenv(name, default)
16
17
18 def env_bool(name: str, default: bool = False) -> bool:
19 return os.getenv(name, str(default)).lower() in ("true", "1", "yes")
20
21
22 def env_int(name: str, default: int = 0) -> int:
23 return int(os.getenv(name, str(default)))
24
25
26 # --- Security ---
27
28 SECRET_KEY = env_str("DJANGO_SECRET_KEY", "change-me-in-production")
29 DEBUG = env_bool("DJANGO_DEBUG", False)
30 ALLOWED_HOSTS = [h.strip() for h in env_str("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")]
31
32 if not DEBUG and SECRET_KEY == "change-me-in-production":
33 raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set to a unique, unpredictable value when DEBUG is False.")
34
35 # --- Application ---
36
37 ROOT_URLCONF = "config.urls"
38 WSGI_APPLICATION = "config.wsgi.application"
39 DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
40 LOGIN_URL = "/auth/login/"
41 LOGIN_REDIRECT_URL = "/"
42 LOGOUT_REDIRECT_URL = "/auth/login/"
43
44 INSTALLED_APPS = [
45 "django.contrib.admin",
46 "django.contrib.auth",
47 "django.contrib.contenttypes",
48 "django.contrib.sessions",
49 "django.contrib.messages",
50 "django.contrib.staticfiles",
51 "django.contrib.humanize",
52 # Third-party
53 "import_export",
54 "simple_history",
55 "django_celery_results",
56 "django_celery_beat",
57 "corsheaders",
58 "constance",
59 "constance.backends.database",
60 # Project apps
61 "core",
62 "auth1",
63 "organization",
64 "items",
65 "projects",
66 "pages",
67 "fossil",
68 "testdata",
69 ]
70
71 MIDDLEWARE = [
72 "corsheaders.middleware.CorsMiddleware",
73 "django.middleware.security.SecurityMiddleware",
74 "whitenoise.middleware.WhiteNoiseMiddleware",
75 "django.contrib.sessions.middleware.SessionMiddleware",
76 "django.middleware.common.CommonMiddleware",
77 "django.middleware.csrf.CsrfViewMiddleware",
78 "django.contrib.auth.middleware.AuthenticationMiddleware",
79 "django.contrib.messages.middleware.MessageMiddleware",
80 "django.middleware.clickjacking.XFrameOptionsMiddleware",
81 "simple_history.middleware.HistoryRequestMiddleware",
82 "core.middleware.current_user.CurrentUserMiddleware",
83 ]
84
85 TEMPLATES = [
86 {
87 "BACKEND": "django.template.backends.django.DjangoTemplates",
88 "DIRS": [BASE_DIR / "templates"],
89 "APP_DIRS": True,
90 "OPTIONS": {
91 "context_processors": [
92 "django.template.context_processors.debug",
93 "django.template.context_processors.request",
94 "django.contrib.auth.context_processors.auth",
95 "django.contrib.messages.context_processors.messages",
96 "core.context_processors.sidebar",
97 ],
98 },
99 },
100 ]
101
102 # --- Database ---
103
104 DATABASES = {
105 "default": {
106 "ENGINE": "django.db.backends.postgresql",
107 "NAME": env_str("POSTGRES_DB", "fossilrepo"),
108 "USER": env_str("POSTGRES_USER", "dbadmin"),
109 "PASSWORD": env_str("POSTGRES_PASSWORD", "Password123"),
110 "HOST": env_str("POSTGRES_HOST", "localhost"),
111 "PORT": env_str("POSTGRES_PORT", "5432"),
112 }
113 }
114
115 # --- Cache ---
116
117 REDIS_URL = env_str("REDIS_URL", "redis://localhost:6379/1")
118
119 CACHES = {
120 "default": {
121 "BACKEND": "django.core.cache.backends.redis.RedisCache",
122 "LOCATION": REDIS_URL,
123 }
124 }
125
126 # --- Auth ---
127
128 AUTH_PASSWORD_VALIDATORS = [
129 {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
130 {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
131 {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
132 {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
133 ]
134
135 AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
136 SESSION_ENGINE = "django.contrib.sessions.backends.db"
137 SESSION_COOKIE_HTTPONLY = True
138 SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 # 30 days
139 CSRF_COOKIE_HTTPONLY = True
140
141 if not DEBUG:
142 True
143 CSRF_COOKIE_SECURE = True
144 SECURE_SSL_REDIRECT = Truept ImproperlyConfigured
145
146 logger = logging.getLogger(__name__)
147
148 VERSION = "0.1.0"
149
150 BASE_DIR = Path(__file__).resolve().parent.parent
151
152
153 def env_str(name: str, default: str | None = None) -> str | None:
154 return os.getenv(name, default)
155
156
157 def env_bool(name: str, default: bool = False) -> bool:
158 return os.getenv(name, str(default)).lower() in ("true", "1", "yes")
159
160
161 def env_int(name: str, default: int = 0) -> int:
162 return int(os.getenv(name, str(default)))
163
164
165 # --- Security ---
166
167 SECRET_KEY = env_str("DJANGO_SECRET_KEY", "change-me-in-production")
168 DEBUG = env_bool("DJANGO_DEBUG", False)
169 ALLOWED_HOSTS = [h.strip() for h in env_str("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")]
170
171 if not DEBUG and SECRET_KEY == "change-me-in-production":
172 raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set to a unique, unpredictable value when DEBUG is False.")
173
174 # --- Application ---
175
176 ROOT_URLCONF = "config.urls"
177 WSGI_APPLICATION = "config.wsgi.application"
178 DEFAULT_AUTO_FIELDges",
179 "fossil",
180 "testd
--- a/config/urls.py
+++ b/config/urls.py
@@ -0,0 +1,37 @@
1
+import time
2
+from datetime import UTC, datetime
3
+
4
+from django.conf import settings
5
+from django.contrib import admin
6
+from django.http import HttpResponseort time
7
+from datetime import UTC, datetime
8
+
9
+from django.conf import settimport time
10
+from datetime import UTC, datetime
11
+
12
+from django.conf import settings
13
+from django.contrib import admin
14
+from django.http import HttpResponse, JsonResponse
15
+from django.shortcuts import redirect as _redirect
16
+from django.urls import include, path
17
+from django.views.generic import RedirectView
18
+
19
+
20
+def _oauth_github_callback(request):
21
+ """Global GitHub OAuth callback. Extracts slug from state pslug = state.split(":")[0] if ":" in state else ""
22
+ if not slugonce = request.separts) < 3:
23
+ retur{slug}/fossil/sync/git/")
24
+
25
+ from fossil.oauth import as eonce = request.sexchange_token(request, slug)
26
+ if result.get("token"):
27
+ request.session["github_oauth_token"] = result["token"]
28
+ request.session["github_oauth_user"] = result.get("username", "")
29
+ return _redirect(f"/projects/{slug}/fossil/sync/git/")
30
+
31
+
32
+def _oauth_gitlab_callbac, "detail": str(e)k(request):
33
+ """Globalslug = state.split(":")[0] if ":" in state else ""
34
+ if not slugonce = request.separts) < 3:
35
+ retur{slug}/fossil/sync/git/")
36
+
37
+ flaonce = request.sexchange_token(reqdashboarduth1items/", include("items
--- a/config/urls.py
+++ b/config/urls.py
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/config/urls.py
+++ b/config/urls.py
@@ -0,0 +1,37 @@
1 import time
2 from datetime import UTC, datetime
3
4 from django.conf import settings
5 from django.contrib import admin
6 from django.http import HttpResponseort time
7 from datetime import UTC, datetime
8
9 from django.conf import settimport time
10 from datetime import UTC, datetime
11
12 from django.conf import settings
13 from django.contrib import admin
14 from django.http import HttpResponse, JsonResponse
15 from django.shortcuts import redirect as _redirect
16 from django.urls import include, path
17 from django.views.generic import RedirectView
18
19
20 def _oauth_github_callback(request):
21 """Global GitHub OAuth callback. Extracts slug from state pslug = state.split(":")[0] if ":" in state else ""
22 if not slugonce = request.separts) < 3:
23 retur{slug}/fossil/sync/git/")
24
25 from fossil.oauth import as eonce = request.sexchange_token(request, slug)
26 if result.get("token"):
27 request.session["github_oauth_token"] = result["token"]
28 request.session["github_oauth_user"] = result.get("username", "")
29 return _redirect(f"/projects/{slug}/fossil/sync/git/")
30
31
32 def _oauth_gitlab_callbac, "detail": str(e)k(request):
33 """Globalslug = state.split(":")[0] if ":" in state else ""
34 if not slugonce = request.separts) < 3:
35 retur{slug}/fossil/sync/git/")
36
37 flaonce = request.sexchange_token(reqdashboarduth1items/", include("items
--- a/config/wsgi.py
+++ b/config/wsgi.py
@@ -0,0 +1,6 @@
1
+import os
2
+
3
+from django.core.wsgi import get_wsgi_application
4
+
5
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
6
+application = get_wsgi_application()
--- a/config/wsgi.py
+++ b/config/wsgi.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/config/wsgi.py
+++ b/config/wsgi.py
@@ -0,0 +1,6 @@
1 import os
2
3 from django.core.wsgi import get_wsgi_application
4
5 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
6 application = get_wsgi_application()
+18
--- a/conftest.py
+++ b/conftest.py
@@ -0,0 +1,18 @@
1
+import pytest
2
+from django.contrib.auth.models import Group, Permission, User
3
+
4
+from organization.models import Organization, OrganizationMember, Team
5
+from pages.models import Page
6
+from projects.models import Project, Pr
7
+def admin_user(db):
8
+ user = User.objects.create_superuser(username="admin", email="[email protected]", password="testpass123")
9
+ return user
10
+
11
+
12
+@pytest.fixture
13
+def viewer_user(db):
14
+ user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
15
+ group, _ = Group.objects.get_or_create(name="Viewers")
16
+ view_perms = Permission.objects.filter(
17
+ content_type__app_label__in=["organization", "projects", items", "pages"],
18
+ codenam
--- a/conftest.py
+++ b/conftest.py
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/conftest.py
+++ b/conftest.py
@@ -0,0 +1,18 @@
1 import pytest
2 from django.contrib.auth.models import Group, Permission, User
3
4 from organization.models import Organization, OrganizationMember, Team
5 from pages.models import Page
6 from projects.models import Project, Pr
7 def admin_user(db):
8 user = User.objects.create_superuser(username="admin", email="[email protected]", password="testpass123")
9 return user
10
11
12 @pytest.fixture
13 def viewer_user(db):
14 user = User.objects.create_user(username="viewer", email="[email protected]", password="testpass123")
15 group, _ = Group.objects.get_or_create(name="Viewers")
16 view_perms = Permission.objects.filter(
17 content_type__app_label__in=["organization", "projects", items", "pages"],
18 codenam

No diff available

--- a/core/admin.py
+++ b/core/admin.py
@@ -0,0 +1,23 @@
1
+from django.contrib import admin
2
+from import_export.admin import ImportExportMixin
3
+
4
+
5
+class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin):
6
+ """Base admin class for all Fossilrepo models. Provides audit field handling and import/export."""
7
+
8
+ def get_yset(request)
9
+
10
+ def get_readonly_fields(self, request, obj=None):
11
+ base = tuple(self.readonly_fields or ())
12
+ return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by")
13
+
14
+ def get_raw_id_fields(self, request):
15
+ base = tuple(self.raw_id_fields or ())
16
+ return base + ("created_by", "updated_by", "deleted_by")
17
+
18
+ def save_model(self, request, obj, form, change):
19
+ if hasattr(obj, "created_by") and not obj.created_by:
20
+ obj.created_by = request.user
21
+ if hasattr(obj, "updated_by"):
22
+ obj.updated_by = request.user
23
+ super().save_model(r
--- a/core/admin.py
+++ b/core/admin.py
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/admin.py
+++ b/core/admin.py
@@ -0,0 +1,23 @@
1 from django.contrib import admin
2 from import_export.admin import ImportExportMixin
3
4
5 class BaseCoreAdmin(ImportExportMixin, admin.ModelAdmin):
6 """Base admin class for all Fossilrepo models. Provides audit field handling and import/export."""
7
8 def get_yset(request)
9
10 def get_readonly_fields(self, request, obj=None):
11 base = tuple(self.readonly_fields or ())
12 return base + ("version", "created_at", "created_by", "updated_at", "updated_by", "deleted_at", "deleted_by")
13
14 def get_raw_id_fields(self, request):
15 base = tuple(self.raw_id_fields or ())
16 return base + ("created_by", "updated_by", "deleted_by")
17
18 def save_model(self, request, obj, form, change):
19 if hasattr(obj, "created_by") and not obj.created_by:
20 obj.created_by = request.user
21 if hasattr(obj, "updated_by"):
22 obj.updated_by = request.user
23 super().save_model(r
--- a/core/apps.py
+++ b/core/apps.py
@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class CoreConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "core"
--- a/core/apps.py
+++ b/core/apps.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/core/apps.py
+++ b/core/apps.py
@@ -0,0 +1,6 @@
1 from django.apps import AppConfig
2
3
4 class CoreConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "core"
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -0,0 +1,18 @@
1
+from pages.models import Page
2
+from projects.models import Project
3
+
4
+
5
+def sidebar(request):
6
+ if not request.user.is_authenticated:
7
+ return {}
8
+
9
+ proje
10
+est.user.is_superuser:
11
+ )
12
+ pages = Page.objects.filter(is_published=True)
13
+ if request.user.has_perm("pages.change_page") or request.user.is_superuser:
14
+ pagesreturn {ouped
15
+ }
16
+return {
17
+ pages": pages,
18
+ }
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/context_processors.py
+++ b/core/context_processors.py
@@ -0,0 +1,18 @@
1 from pages.models import Page
2 from projects.models import Project
3
4
5 def sidebar(request):
6 if not request.user.is_authenticated:
7 return {}
8
9 proje
10 est.user.is_superuser:
11 )
12 pages = Page.objects.filter(is_published=True)
13 if request.user.has_perm("pages.change_page") or request.user.is_superuser:
14 pagesreturn {ouped
15 }
16 return {
17 pages": pages,
18 }

No diff available

No diff available

--- a/core/middleware/current_user.py
+++ b/core/middleware/current_user.py
@@ -0,0 +1,19 @@
1
+import threading
2
+
3
+_thread_local = threading.local()
4
+
5
+
6
+def get_current_user():
7
+ return getattr(_thread_local, "user", None)
8
+
9
+
10
+class CurrentUserMiddleware:
11
+ """Store the current user on thread-local storage for use in signals and model save methods."""
12
+
13
+ def __init__(self, get_response):
14
+ self.get_response = get_response
15
+
16
+ def __call__(self, request):
17
+ _thread_local.user = getattr(request, "user", None)
18
+ response = self.get_response(request)
19
+ return response
--- a/core/middleware/current_user.py
+++ b/core/middleware/current_user.py
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/middleware/current_user.py
+++ b/core/middleware/current_user.py
@@ -0,0 +1,19 @@
1 import threading
2
3 _thread_local = threading.local()
4
5
6 def get_current_user():
7 return getattr(_thread_local, "user", None)
8
9
10 class CurrentUserMiddleware:
11 """Store the current user on thread-local storage for use in signals and model save methods."""
12
13 def __init__(self, get_response):
14 self.get_response = get_response
15
16 def __call__(self, request):
17 _thread_local.user = getattr(request, "user", None)
18 response = self.get_response(request)
19 return response

No diff available

--- a/core/models.py
+++ b/core/models.py
@@ -0,0 +1,74 @@
1
+import uuid
2
+
3
+from django.conf import settings
4
+from django.db import models
5
+from django.utils import timezone
6
+from django.utils.text import slugify
7
+from simple_history.models import HistoricalRecords
8
+
9
+
10
+class Tracking(models.Model):
11
+ """Abstract base providing audit trails and soft deletes for all business models."""
12
+
13
+ version = models.PositiveIntegerField(default=1, editable=False)
14
+ created_at = models.DateTimeField(auto_now_add=True)
15
+ created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="+")
16
+ updated_at = models.DateTimeField(auto_now=True)
17
+ updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="+")
18
+ deleted_at = models.DateTimeField(null=True, blank=True)
19
+ deleted_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="+")
20
+ history = HistoricalRecords(inherit=True)
21
+
22
+ class Meta:
23
+ abstract = True
24
+
25
+ def save(self, *args, **kwargs):
26
+ if self.pk:
27
+ self.version += 1
28
+ super().save(*args, **kwargs)
29
+
30
+ def soft_delete(self, user=None):
31
+ self.deleted_at = timezone.now()
32
+ self.deleted_by = user
33
+ self.save(update_fields=["deleted_at", "deleted_by", "updated_at", "version"])
34
+
35
+ @property
36
+ def is_deleted(self):
37
+ return self.deleted_at is not None
38
+
39
+
40
+class BaseCoreModel(Tracking):
41
+ """Abstract base for named, addressable entities with UUID external identifiers."""
42
+
43
+ guid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
44
+ name = models.CharField(max_length=200)
45
+ slug = models.SlugField(max_length=200, unique=True, db_index=True)
46
+ description = models.TextField(blank=True, default="")
47
+
48
+ class Meta:
49
+ abstract = True
50
+
51
+ def __str__(self):
52
+ return self.name
53
+
54
+ def save(self, *args, **kwargs):
55
+ if not self.slug:
56
+ base_slug = slugify(self.name)
57
+ slug = base_slug
58
+ counter = 1
59
+ model_class = type(self)
60
+ while model_class.objects.filter(slug=slug).exclude(pk=self.pk).exists():
61
+ slug = f"{base_slug}-{counter}"
62
+ counter += 1
63
+ self.slug = slug
64
+ super().save(*args, **kwargs)
65
+
66
+ def get_absolute_url(self):
67
+ return f"/{self._meta.app_label}/{self.slug}/"
68
+
69
+
70
+class ActiveManager(models.Manager):
71
+ """Manager that excludes soft-deleted records by default."""
72
+
73
+ def get_queryset(self):
74
+ return super().get_queryset().filter(deleted_at__isnull=True)
--- a/core/models.py
+++ b/core/models.py
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/models.py
+++ b/core/models.py
@@ -0,0 +1,74 @@
1 import uuid
2
3 from django.conf import settings
4 from django.db import models
5 from django.utils import timezone
6 from django.utils.text import slugify
7 from simple_history.models import HistoricalRecords
8
9
10 class Tracking(models.Model):
11 """Abstract base providing audit trails and soft deletes for all business models."""
12
13 version = models.PositiveIntegerField(default=1, editable=False)
14 created_at = models.DateTimeField(auto_now_add=True)
15 created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="+")
16 updated_at = models.DateTimeField(auto_now=True)
17 updated_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="+")
18 deleted_at = models.DateTimeField(null=True, blank=True)
19 deleted_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="+")
20 history = HistoricalRecords(inherit=True)
21
22 class Meta:
23 abstract = True
24
25 def save(self, *args, **kwargs):
26 if self.pk:
27 self.version += 1
28 super().save(*args, **kwargs)
29
30 def soft_delete(self, user=None):
31 self.deleted_at = timezone.now()
32 self.deleted_by = user
33 self.save(update_fields=["deleted_at", "deleted_by", "updated_at", "version"])
34
35 @property
36 def is_deleted(self):
37 return self.deleted_at is not None
38
39
40 class BaseCoreModel(Tracking):
41 """Abstract base for named, addressable entities with UUID external identifiers."""
42
43 guid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True)
44 name = models.CharField(max_length=200)
45 slug = models.SlugField(max_length=200, unique=True, db_index=True)
46 description = models.TextField(blank=True, default="")
47
48 class Meta:
49 abstract = True
50
51 def __str__(self):
52 return self.name
53
54 def save(self, *args, **kwargs):
55 if not self.slug:
56 base_slug = slugify(self.name)
57 slug = base_slug
58 counter = 1
59 model_class = type(self)
60 while model_class.objects.filter(slug=slug).exclude(pk=self.pk).exists():
61 slug = f"{base_slug}-{counter}"
62 counter += 1
63 self.slug = slug
64 super().save(*args, **kwargs)
65
66 def get_absolute_url(self):
67 return f"/{self._meta.app_label}/{self.slug}/"
68
69
70 class ActiveManager(models.Manager):
71 """Manager that excludes soft-deleted records by default."""
72
73 def get_queryset(self):
74 return super().get_queryset().filter(deleted_at__isnull=True)
--- a/core/permissions.py
+++ b/core/permissions.py
@@ -0,0 +1,66 @@
1
+import logging
2
+from enum import Enum
3
+
4
+from django.core.exceptions import PermissionDenied
5
+
6
+logger = logging.getLogger(__name__)
7
+
8
+
9
+class P(Enum):
10
+ """Permission enum. Check permissions via P.PERMISSION_NAME.check(user)."""
11
+
12
+ # Organization
13
+ ORGANIZATION_VIEW = "organization.view_organization"
14
+ ORGANIZATION_ADD = "organization.add_organization"
15
+ ORGANIZATION_CHANGE = "organization.change_organization"
16
+ ORGANIZATION_DELETE = "organization.delete_organization"
17
+
18
+ # Organization Members
19
+ ORGANIZATION_MEMBER_VIEW = "organization.view_organizationmember"
20
+ ORGANIZATION_MEMBER_ADD = "organization.add_organizationmember"
21
+ ORGANIZATION_MEMBER_CHANGE = "organization.change_organizationmember"
22
+ ORGANIZATION_MEMBER_DELETE = "organization.delete_organizationmember"
23
+
24
+ # Teams
25
+ TEAM_VIEW = "organization.view_team"
26
+ TEAM_ADD = "organization.add_team"
27
+ TEAM_CHANGE = "organization.change_team"
28
+ TEAM_DELETE = "organization.delete_team"
29
+
30
+ # Projects
31
+ PROJECT_VIEW = "projects.view_project"
32
+ PROJECT_ADD = "projects.add_project"
33
+ PROJECT_CHANGE = "projects.change_project"
34
+ PROJECT_DELETE = "projects.delete_project"
35
+
36
+ # Fossil
37
+ FOSSIL_VIEW = "fossil.view_fossilrepository"
38
+ FOSSIL_ADD = "fossil.add_fossilrepository"
39
+ FOSSIL_CHANGE = "fossil.change_fossilrepository"
40
+ FOSSIL_DELETE = "fossil.delete_fossilrepository"
41
+
42
+ # Pages (docs)
43
+ PAGE_VIEW = "pages.view_page"
44
+ PAGE_ADD = "pages.add_page"
45
+ PAGE_CHANGE = "pages.change_page"
46
+ PAGE_DELETE # Items (example domain)
47
+ ITEM_VIEW = "items.view_item"
48
+ ITEManizatioitems.add_item"
49
+ ITEM_CHANGE = "items.change_item"
50
+ ITEM_DELETE = "items.delete_itemDELETE = "pages.delete_page"
51
+
52
+ def check(self, user, raise_error=True):
53
+ """Check if user has this permission. Superusers always pass."""
54
+ if not user or not user.is_authenticated:
55
+ if raise_error:
56
+ raise PermissionDenied("Authentication required.")
57
+ return False
58
+
59
+ if user.is_superuser:
60
+ return True
61
+
62
+ if user.has_perm(self.value):
63
+ return True
64
+
65
+ if raise_error:
66
+ raise PermissionDenied(f"Permission denied: {self.val
--- a/core/permissions.py
+++ b/core/permissions.py
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/permissions.py
+++ b/core/permissions.py
@@ -0,0 +1,66 @@
1 import logging
2 from enum import Enum
3
4 from django.core.exceptions import PermissionDenied
5
6 logger = logging.getLogger(__name__)
7
8
9 class P(Enum):
10 """Permission enum. Check permissions via P.PERMISSION_NAME.check(user)."""
11
12 # Organization
13 ORGANIZATION_VIEW = "organization.view_organization"
14 ORGANIZATION_ADD = "organization.add_organization"
15 ORGANIZATION_CHANGE = "organization.change_organization"
16 ORGANIZATION_DELETE = "organization.delete_organization"
17
18 # Organization Members
19 ORGANIZATION_MEMBER_VIEW = "organization.view_organizationmember"
20 ORGANIZATION_MEMBER_ADD = "organization.add_organizationmember"
21 ORGANIZATION_MEMBER_CHANGE = "organization.change_organizationmember"
22 ORGANIZATION_MEMBER_DELETE = "organization.delete_organizationmember"
23
24 # Teams
25 TEAM_VIEW = "organization.view_team"
26 TEAM_ADD = "organization.add_team"
27 TEAM_CHANGE = "organization.change_team"
28 TEAM_DELETE = "organization.delete_team"
29
30 # Projects
31 PROJECT_VIEW = "projects.view_project"
32 PROJECT_ADD = "projects.add_project"
33 PROJECT_CHANGE = "projects.change_project"
34 PROJECT_DELETE = "projects.delete_project"
35
36 # Fossil
37 FOSSIL_VIEW = "fossil.view_fossilrepository"
38 FOSSIL_ADD = "fossil.add_fossilrepository"
39 FOSSIL_CHANGE = "fossil.change_fossilrepository"
40 FOSSIL_DELETE = "fossil.delete_fossilrepository"
41
42 # Pages (docs)
43 PAGE_VIEW = "pages.view_page"
44 PAGE_ADD = "pages.add_page"
45 PAGE_CHANGE = "pages.change_page"
46 PAGE_DELETE # Items (example domain)
47 ITEM_VIEW = "items.view_item"
48 ITEManizatioitems.add_item"
49 ITEM_CHANGE = "items.change_item"
50 ITEM_DELETE = "items.delete_itemDELETE = "pages.delete_page"
51
52 def check(self, user, raise_error=True):
53 """Check if user has this permission. Superusers always pass."""
54 if not user or not user.is_authenticated:
55 if raise_error:
56 raise PermissionDenied("Authentication required.")
57 return False
58
59 if user.is_superuser:
60 return True
61
62 if user.has_perm(self.value):
63 return True
64
65 if raise_error:
66 raise PermissionDenied(f"Permission denied: {self.val

No diff available

--- a/core/templatetags/permissions_tags.py
+++ b/core/templatetags/permissions_tags.py
@@ -0,0 +1,13 @@
1
+from django import template
2
+
3
+register = template.Library()
4
+
5
+
6
+@register.simple_tag(takes_context=True)
7
+def has_perm(context, perm_string):
8
+ """Check if the current user has a specific permission. Usage: {% has_perm 'items.view_itemperm 'projects.view_project' as can_view %}"""
9
+ user = context.get("user") or context["request"].user
10
+ if not user or not user.is_authenticated:
11
+ return False
12
+ if user.is_superuser:
13
+
--- a/core/templatetags/permissions_tags.py
+++ b/core/templatetags/permissions_tags.py
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/templatetags/permissions_tags.py
+++ b/core/templatetags/permissions_tags.py
@@ -0,0 +1,13 @@
1 from django import template
2
3 register = template.Library()
4
5
6 @register.simple_tag(takes_context=True)
7 def has_perm(context, perm_string):
8 """Check if the current user has a specific permission. Usage: {% has_perm 'items.view_itemperm 'projects.view_project' as can_view %}"""
9 user = context.get("user") or context["request"].user
10 if not user or not user.is_authenticated:
11 return False
12 if user.is_superuser:
13
+149
--- a/core/tests.py
+++ b/core/tests.py
@@ -0,0 +1,149 @@
1
+import pytest
2
+from django.contrib.auth.models import User
3
+from django.test import TestCase
4
+from django.urls import reverse
5
+
6
+from .permissions import P
7
+
8
+
9
+class TrackingModelTest(TestCase):
10
+ """Test the Tracking abstract model via a concrete model that uses it."""
11
+
12
+ defitems.models import Item
13
+
14
+ self.user = User.objects.create_superuser(username="test", password="x")
15
+ self.item = Itemoject
16
+
17
+ p2 = PrTest Widget", price="9.99", created_by=self.user)
18
+
19
+ def test_version_increments_on_save(self):
20
+ initial_version = self.itemtial_version = self.pritem.name = "Updated Widgetst_slug self.item.refresh_from_db()
21
+ item.version, initial_version + 1)
22
+
23
+ def test_soft_delete_sets_deleted_at(self):
24
+ self.itemself):
25
+ item.refresh_frIsNotNone(self.item.deleted_at)
26
+ item.deleted_by, self.user)
27
+ item.is_deleted)
28
+
29
+ def test_created_at_auto_set(self):
30
+ self.assertIsNotNone(self.itemssertIsNotNone(self.project.created_at)
31
+
32
+ def test_updated_at_auto_set(self):
33
+ item.updated_at)
34
+
35
+
36
+class BaseCoreModelTest(TestCase):
37
+ """Test BaseCoreModel slug generation and UUID."""
38
+
39
+ def setUp(self):
40
+ from items.models import Item
41
+
42
+ self.user = User.objects.create_superuser(username="test", password="x")
43
+ self.item = Itemoject
44
+
45
+ p2 = ProjeItem", price="19.99", created_by=self.user)
46
+
47
+ def test_slug_auto_generated(self):
48
+ self.assertEqual(self.item.slug, "my-item"m organization.mguid_is_uui):
49
+ self.aimport uuid
50
+
51
+ item.guid, uuid.UUID)
52
+
53
+ def test_slug_uniqueness(self):
54
+ from items.models import Item
55
+
56
+g", cre p2 = Itemoject
57
+
58
+ p2 = ProjeItem", price="29.99", created_assertNotEqual(self.itemcontr_deleted)
59
+
60
+ de initial_version = self.projitem), "My Item Project"
61
+ self.project.save()
62
+ self.project.refresh_from_db()
63
+ self.assertEqual(self.project.version, initial_version + 1)
64
+
65
+ def test_soft_delete_sets_deleted_at(self):
66
+ self.project.soft_delete(user=self.user)
67
+ self.project.refresh_from_db()
68
+ self.assertIsNotNone(self.project.deleted_at)
69
+ ITEMt.deleted_at)
70
+ self.aITEM_ADDeted_at)
71
+ self.as.updated_at)
72
+denied(self):
73
+ create_superuser(username="test", password="x")
74
+ self.org = Organization.objects.crITEMpdated_at)
75
+
76
+
77
+class BaseCoreModelTest(TestCase):
78
+ """Test BaseCoreModel slug generatITEMpdated_at)
79
+
80
+
81
+class Baseization.models import Organization
82
+ from projects.models import Project
83
+
84
+ self.user = User.objects.create_superuser(username="test", password="x")
85
+ self.org = Organization.objects.create(name="Test Org", created_by=self.user)
86
+ seITEMntrib.auth.models import User
87
+from django.test import TestCase
88
+from django.urls import reverse
89
+
90
+from .permissions import P
91
+
92
+
93
+class TrackingModelTest(TestCase):
94
+ """Test the Tracking abstract model via a concrete model that uses it."""
95
+
96
+ def setUp(self):
97
+ from organization.models import Organization
98
+ from projects.models import Project
99
+
100
+ self.user = User.objects.create_superuser(username="test", password="x")
101
+ self.org = Organization.objects.create(name="Test Org", created_by=self.user)
102
+ self.project = Project.objects.create(name="Test Project", organization=self.org, created_by=self.user)
103
+
104
+ def test_version_increments_on_save(self):
105
+ initial_version = self.project.version
106
+ self.project.name = "Updated Project"
107
+ self.project.save()
108
+ self.project.refresh_from_db()
109
+ self.assertEqual(self.project.version, initial_version + 1)
110
+
111
+ def test_soft_delete_sets_deleted_at(self):
112
+ self.project.soft_delete(user=self.user)
113
+ self.project.refresh_from_db()
114
+ self.assertIsNotNone(self.project.deleted_at)
115
+ self.assertEqual(self.project.deleted_by, self.user)
116
+ self.assertTrue(self.project.is_deleted)
117
+
118
+ def test_created_at_auto_set(self):
119
+ self.assertIsNotNone(self.project.created_at)
120
+
121
+ def test_updated_at_auto_set(self):
122
+ self.assertIsNotNone(self.project.updated_at)
123
+
124
+
125
+class BaseCoreModelTest(TestCase):
126
+ """Test BaseCoreModel slug generation and UUID."""
127
+
128
+ def setUp(self):
129
+ from organization.models import Organization
130
+ from projects.models import Project
131
+
132
+ self.user = User.objects.create_superuser(username="test", password="x")
133
+ self.org = Organization.objects.create(name="Test Org", created_by=self.user)
134
+ self.project = Project.objects.create(name="My Project", organization=self.org, created_by=self.usimport pytest
135
+from django.contrib.auth.models import User
136
+from django.test import TestCase
137
+from django.urls import reverse
138
+
139
+from .permissions import P
140
+
141
+
142
+class TrackingModelTest(TestCase):
143
+ """Test the Tracking abstract model via a concrete model that uses it."""
144
+
145
+ def setUp(self):
146
+ from organization.models import Organization
147
+ from projects.models import Project
148
+
149
+ self.user = User.obj
--- a/core/tests.py
+++ b/core/tests.py
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/core/tests.py
+++ b/core/tests.py
@@ -0,0 +1,149 @@
1 import pytest
2 from django.contrib.auth.models import User
3 from django.test import TestCase
4 from django.urls import reverse
5
6 from .permissions import P
7
8
9 class TrackingModelTest(TestCase):
10 """Test the Tracking abstract model via a concrete model that uses it."""
11
12 defitems.models import Item
13
14 self.user = User.objects.create_superuser(username="test", password="x")
15 self.item = Itemoject
16
17 p2 = PrTest Widget", price="9.99", created_by=self.user)
18
19 def test_version_increments_on_save(self):
20 initial_version = self.itemtial_version = self.pritem.name = "Updated Widgetst_slug self.item.refresh_from_db()
21 item.version, initial_version + 1)
22
23 def test_soft_delete_sets_deleted_at(self):
24 self.itemself):
25 item.refresh_frIsNotNone(self.item.deleted_at)
26 item.deleted_by, self.user)
27 item.is_deleted)
28
29 def test_created_at_auto_set(self):
30 self.assertIsNotNone(self.itemssertIsNotNone(self.project.created_at)
31
32 def test_updated_at_auto_set(self):
33 item.updated_at)
34
35
36 class BaseCoreModelTest(TestCase):
37 """Test BaseCoreModel slug generation and UUID."""
38
39 def setUp(self):
40 from items.models import Item
41
42 self.user = User.objects.create_superuser(username="test", password="x")
43 self.item = Itemoject
44
45 p2 = ProjeItem", price="19.99", created_by=self.user)
46
47 def test_slug_auto_generated(self):
48 self.assertEqual(self.item.slug, "my-item"m organization.mguid_is_uui):
49 self.aimport uuid
50
51 item.guid, uuid.UUID)
52
53 def test_slug_uniqueness(self):
54 from items.models import Item
55
56 g", cre p2 = Itemoject
57
58 p2 = ProjeItem", price="29.99", created_assertNotEqual(self.itemcontr_deleted)
59
60 de initial_version = self.projitem), "My Item Project"
61 self.project.save()
62 self.project.refresh_from_db()
63 self.assertEqual(self.project.version, initial_version + 1)
64
65 def test_soft_delete_sets_deleted_at(self):
66 self.project.soft_delete(user=self.user)
67 self.project.refresh_from_db()
68 self.assertIsNotNone(self.project.deleted_at)
69 ITEMt.deleted_at)
70 self.aITEM_ADDeted_at)
71 self.as.updated_at)
72 denied(self):
73 create_superuser(username="test", password="x")
74 self.org = Organization.objects.crITEMpdated_at)
75
76
77 class BaseCoreModelTest(TestCase):
78 """Test BaseCoreModel slug generatITEMpdated_at)
79
80
81 class Baseization.models import Organization
82 from projects.models import Project
83
84 self.user = User.objects.create_superuser(username="test", password="x")
85 self.org = Organization.objects.create(name="Test Org", created_by=self.user)
86 seITEMntrib.auth.models import User
87 from django.test import TestCase
88 from django.urls import reverse
89
90 from .permissions import P
91
92
93 class TrackingModelTest(TestCase):
94 """Test the Tracking abstract model via a concrete model that uses it."""
95
96 def setUp(self):
97 from organization.models import Organization
98 from projects.models import Project
99
100 self.user = User.objects.create_superuser(username="test", password="x")
101 self.org = Organization.objects.create(name="Test Org", created_by=self.user)
102 self.project = Project.objects.create(name="Test Project", organization=self.org, created_by=self.user)
103
104 def test_version_increments_on_save(self):
105 initial_version = self.project.version
106 self.project.name = "Updated Project"
107 self.project.save()
108 self.project.refresh_from_db()
109 self.assertEqual(self.project.version, initial_version + 1)
110
111 def test_soft_delete_sets_deleted_at(self):
112 self.project.soft_delete(user=self.user)
113 self.project.refresh_from_db()
114 self.assertIsNotNone(self.project.deleted_at)
115 self.assertEqual(self.project.deleted_by, self.user)
116 self.assertTrue(self.project.is_deleted)
117
118 def test_created_at_auto_set(self):
119 self.assertIsNotNone(self.project.created_at)
120
121 def test_updated_at_auto_set(self):
122 self.assertIsNotNone(self.project.updated_at)
123
124
125 class BaseCoreModelTest(TestCase):
126 """Test BaseCoreModel slug generation and UUID."""
127
128 def setUp(self):
129 from organization.models import Organization
130 from projects.models import Project
131
132 self.user = User.objects.create_superuser(username="test", password="x")
133 self.org = Organization.objects.create(name="Test Org", created_by=self.user)
134 self.project = Project.objects.create(name="My Project", organization=self.org, created_by=self.usimport pytest
135 from django.contrib.auth.models import User
136 from django.test import TestCase
137 from django.urls import reverse
138
139 from .permissions import P
140
141
142 class TrackingModelTest(TestCase):
143 """Test the Tracking abstract model via a concrete model that uses it."""
144
145 def setUp(self):
146 from organization.models import Organization
147 from projects.models import Project
148
149 self.user = User.obj
--- a/core/urls.py
+++ b/core/urls.py
@@ -0,0 +1,7 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+urlpatterns = [
6
+ path("", views.dashboard, name="dashboard"),
7
+]
--- a/core/urls.py
+++ b/core/urls.py
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
--- a/core/urls.py
+++ b/core/urls.py
@@ -0,0 +1,7 @@
1 from django.urls import path
2
3 from . import views
4
5 urlpatterns = [
6 path("", views.dashboard, name="dashboard"),
7 ]
--- a/core/views.py
+++ b/core/views.py
@@ -0,0 +1,3 @@
1
+import json
2
+
3
+from django.contrib.
--- a/core/views.py
+++ b/core/views.py
@@ -0,0 +1,3 @@
 
 
 
--- a/core/views.py
+++ b/core/views.py
@@ -0,0 +1,3 @@
1 import json
2
3 from django.contrib.

No diff available

--- a/ctl/main.py
+++ b/ctl/main.py
@@ -0,0 +1 @@
1
+"
--- a/ctl/main.py
+++ b/ctl/main.py
@@ -0,0 +1 @@
 
--- a/ctl/main.py
+++ b/ctl/main.py
@@ -0,0 +1 @@
1 "
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -0,0 +1,94 @@
1
+services:
2
+ backend:
3
+ build: .
4
+ command: python manage.py runserver 0.0.0.0:8000
5
+ portenv_file: .env.example
6
+ environment:
7
+ DJANGO_DEBUG: "true"
8
+ POSTGRES_HOST: postgres
9
+ REDIS_URL: redis://redis:6379/1
10
+ CELERY_BROKER: redis://redis:6379/0
11
+ EMAIL_HOST: mailpit
12
+ volumfossil-ces:
13
+ backend:
14
+ build: .
15
+ command: python manage.py runserver 0.0.0.0:8000
16
+ ports:
17
+ - "8000:8000"
18
+ - "2222:2222"
19
+ env_file: .env.example
20
+ environment:
21
+ DJANGO_DEBUG: "true"
22
+ POSTGRES_HOST: postgres
23
+ REDIS_URL: redis://redis:6379/1
24
+ CELERY_BROKER: redis://redis:6379/0
25
+ EMAIL_HOST: mailpit
26
+ volumes:
27
+ - .:/app
28
+ - ./repos:/data/repos
29
+ depends_on:
30
+ postgres:
31
+ condition: service_healthy
32
+ redis:
33
+ condition: service_healthy
34
+
35
+ celery-worker:
36
+ build: .
37
+ command: celery -A config.celery worker -l info -Q celery
38
+ env_file: .env.example
39
+ environment:
40
+ POSTGRES_HOST: postgres
41
+ REDIS_URL: redis://redis:6379/1
42
+ CELERY_BROKER: redis://redis:6379/0
43
+ volumes:
44
+ - .:/app
45
+ depends_on:
46
+ postgres:
47
+ condition: service_healthy
48
+ redis:
49
+ condition: service_healthy
50
+
51
+ celery-beat:
52
+ build: .
53
+ command: celery -A config.celery beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
54
+ env_file: .env.example
55
+ environment:
56
+ POSTGRES_HOST: postgres
57
+ REDIS_URL: redis://redis:6379/1
58
+ CELERY_BROKER: redis://redis:6379/0
59
+ volumes:
60
+ - .:/app
61
+ depends_on:
62
+ postgres:
63
+ condition: service_healthy
64
+ redis:
65
+ condition: service_healthy
66
+
67
+ postgres:
68
+ image: postgres:16-alpine
69
+ ports:
70
+ - "5432:5432"
71
+ environment:
72
+ POSTGRES_DB: fossilrepo
73
+ POSTGRES_USER: dbadmin
74
+ # Dev-only credentials. Override via .env in production.
75
+ POSTGRES_PASSWORD: Password123
76
+ volumes:
77
+ - pgdata:/var/lib/postgresql/data
78
+ healthcheck:
79
+ test: ["CMD-SHELL", "pg_isready -U dbadmin -d fossilrepo"]
80
+ interval: 5s
81
+ timeout: 5s
82
+ retries: 5
83
+
84
+ redis:
85
+ image: redis:7-alpine
86
+ ports:
87
+ - "6379:6379"
88
+ healthcheck:
89
+ test: ["CMD", "redis-cli", "ping"]
90
+ interval: 5s
91
+ timeout: 5s
92
+ services:
93
+ back
94
+3CLN8M;
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -0,0 +1,94 @@
1 services:
2 backend:
3 build: .
4 command: python manage.py runserver 0.0.0.0:8000
5 portenv_file: .env.example
6 environment:
7 DJANGO_DEBUG: "true"
8 POSTGRES_HOST: postgres
9 REDIS_URL: redis://redis:6379/1
10 CELERY_BROKER: redis://redis:6379/0
11 EMAIL_HOST: mailpit
12 volumfossil-ces:
13 backend:
14 build: .
15 command: python manage.py runserver 0.0.0.0:8000
16 ports:
17 - "8000:8000"
18 - "2222:2222"
19 env_file: .env.example
20 environment:
21 DJANGO_DEBUG: "true"
22 POSTGRES_HOST: postgres
23 REDIS_URL: redis://redis:6379/1
24 CELERY_BROKER: redis://redis:6379/0
25 EMAIL_HOST: mailpit
26 volumes:
27 - .:/app
28 - ./repos:/data/repos
29 depends_on:
30 postgres:
31 condition: service_healthy
32 redis:
33 condition: service_healthy
34
35 celery-worker:
36 build: .
37 command: celery -A config.celery worker -l info -Q celery
38 env_file: .env.example
39 environment:
40 POSTGRES_HOST: postgres
41 REDIS_URL: redis://redis:6379/1
42 CELERY_BROKER: redis://redis:6379/0
43 volumes:
44 - .:/app
45 depends_on:
46 postgres:
47 condition: service_healthy
48 redis:
49 condition: service_healthy
50
51 celery-beat:
52 build: .
53 command: celery -A config.celery beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
54 env_file: .env.example
55 environment:
56 POSTGRES_HOST: postgres
57 REDIS_URL: redis://redis:6379/1
58 CELERY_BROKER: redis://redis:6379/0
59 volumes:
60 - .:/app
61 depends_on:
62 postgres:
63 condition: service_healthy
64 redis:
65 condition: service_healthy
66
67 postgres:
68 image: postgres:16-alpine
69 ports:
70 - "5432:5432"
71 environment:
72 POSTGRES_DB: fossilrepo
73 POSTGRES_USER: dbadmin
74 # Dev-only credentials. Override via .env in production.
75 POSTGRES_PASSWORD: Password123
76 volumes:
77 - pgdata:/var/lib/postgresql/data
78 healthcheck:
79 test: ["CMD-SHELL", "pg_isready -U dbadmin -d fossilrepo"]
80 interval: 5s
81 timeout: 5s
82 retries: 5
83
84 redis:
85 image: redis:7-alpine
86 ports:
87 - "6379:6379"
88 healthcheck:
89 test: ["CMD", "redis-cli", "ping"]
90 interval: 5s
91 timeout: 5s
92 services:
93 back
94 3CLN8M;
--- a/docker/Caddyfile
+++ b/docker/Caddyfile
@@ -0,0 +1,23 @@
1
+# fossilrepo Caddy configuration
2
+#
3
+# Routes *.{domain} subdomains to the fossil server.
4
+# Each repo gets its own subdomain: reponame.fossilrepos.io
5
+#
6
+# In production, replace {$FOSSILREPO_CADDY_DOMAIN} with your domain
7
+# or set the environment variable.
8
+
9
+# Wildcard subdomain routing to fossil server
10
+*.{$FOSSILREPO_CADDY_DOMAIN:localhost} {
11
+ # Extract repo name from subdomain
12
+ @repo host *.{$FOSSILREPO_CADDY_DOMAIN:localhost}
13
+
14
+ # Reverse proxy to fossil server
15
+ # fossil server --repolist serves all repos under /data/repos/
16
+ # and routes by the first path segment or subdomain
17
+ reverse_proxy @repo localhost:8080
18
+}
19
+
20
+# Root domain — landing page or redirect
21
+{$FOSSILREPO_CADDY_DOMAIN:localhost} {
22
+ respond "fossilrepo server running" 200
23
+}
--- a/docker/Caddyfile
+++ b/docker/Caddyfile
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/Caddyfile
+++ b/docker/Caddyfile
@@ -0,0 +1,23 @@
1 # fossilrepo Caddy configuration
2 #
3 # Routes *.{domain} subdomains to the fossil server.
4 # Each repo gets its own subdomain: reponame.fossilrepos.io
5 #
6 # In production, replace {$FOSSILREPO_CADDY_DOMAIN} with your domain
7 # or set the environment variable.
8
9 # Wildcard subdomain routing to fossil server
10 *.{$FOSSILREPO_CADDY_DOMAIN:localhost} {
11 # Extract repo name from subdomain
12 @repo host *.{$FOSSILREPO_CADDY_DOMAIN:localhost}
13
14 # Reverse proxy to fossil server
15 # fossil server --repolist serves all repos under /data/repos/
16 # and routes by the first path segment or subdomain
17 reverse_proxy @repo localhost:8080
18 }
19
20 # Root domain — landing page or redirect
21 {$FOSSILREPO_CADDY_DOMAIN:localhost} {
22 respond "fossilrepo server running" 200
23 }
--- a/docker/Dockerfile.fossil
+++ b/docker/Dockerfile.fossil
@@ -0,0 +1,81 @@
1
+# fossilrepo omnibus — Fossil + Caddy + Litestream
2
+#
3
+# Builds Fossil from source for version locking. Serves Fossil repos
4
+# with automatic SSL via Caddy and continuous S3 replication via Litestream.
5
+# Everything is compiled/pinned — no distro package dependencies at runtime.
6
+
7
+# ── Stage 1: Build Fossil from source ──────────────────────────────────────
8
+
9
+FROM debian:bookworm-slim AS fossil-builder
10
+
11
+ARG FOSSIL_VERSION=2.24
12
+
13
+RUN apt-get update && apt-get install -y --no-install-recommends \
14
+ build-essential \
15
+ curl \
16
+ ca-certificates \
17
+ zlib1g-dev \
18
+ libssl-dev \
19
+ tcl \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+WORKDIR /build
23
+
24
+RUN curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \
25
+ -o fossil.tar.gz \
26
+ && tar xzf fossil.tar.gz \
27
+ && cd fossil-src-${FOSSIL_VERSION} \
28
+ && ./configure --prefix=/usr/local --with-openssl=auto --json \
29
+ && make -j$(nproc) \
30
+ && make install \
31
+ && fossil version
32
+
33
+# ── Stage 2: Runtime image ─────────────────────────────────────────────────
34
+
35
+FROM python:3.12-slim AS base
36
+
37
+# Version pins — change these to upgrade
38
+ARG LITESTREAM_VERSION=0.3.13
39
+ARG CADDY_VERSION=2.9
40
+
41
+# Runtime deps only (no build tools)
42
+RUN apt-get update && apt-get install -y --no-install-recommends \
43
+ zlib1g \
44
+ libssl3 \
45
+ curl \
46
+ ca-certificates \
47
+ && rm -rf /var/lib/apt/lists/*
48
+
49
+# Copy Fossil binary from builder
50
+COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
51
+
52
+# Install Caddy (pinned)
53
+RUN curl -sSL "https://caddyserver.com/api/download?os=linux&arch=amd64&version=v${CADDY_VERSION}" \
54
+ -o /usr/local/bin/caddy \
55
+ && chmod +x /usr/local/bin/caddy
56
+
57
+# Install Litestream (pinned)
58
+RUN curl -sSL "https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-amd64.tar.gz" \
59
+ | tar -xz -C /usr/local/bin/
60
+
61
+# Verify all binaries
62
+RUN fossil version && caddy version && litestream version
63
+
64
+# Create data directories
65
+RUN mkdir -p /data/repos /data/trash /etc/caddy
66
+
67
+# Copy configuration files
68
+COPY Caddyfile /etc/caddy/Caddyfile
69
+COPY litestream.yml /etc/litestream.yml
70
+
71
+# Copy and install the fossilrepo package
72
+COPY .. /app
73
+WORKDIR /app
74
+RUN pip install --no-cache-dir .
75
+
76
+# Expose ports: Caddy HTTPS (443), Caddy HTTP (80), Fossil direct (8080)
77
+EXPOSE 80 443 8080
78
+
79
+# Litestream wraps the fossil server process, replicating all .fossil
80
+# files to S3 continuously while the server runs.
81
+CMD ["litestream", "replicate", "-exec", "caddy run --config /etc/caddy/Caddyfile"]
--- a/docker/Dockerfile.fossil
+++ b/docker/Dockerfile.fossil
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/Dockerfile.fossil
+++ b/docker/Dockerfile.fossil
@@ -0,0 +1,81 @@
1 # fossilrepo omnibus — Fossil + Caddy + Litestream
2 #
3 # Builds Fossil from source for version locking. Serves Fossil repos
4 # with automatic SSL via Caddy and continuous S3 replication via Litestream.
5 # Everything is compiled/pinned — no distro package dependencies at runtime.
6
7 # ── Stage 1: Build Fossil from source ──────────────────────────────────────
8
9 FROM debian:bookworm-slim AS fossil-builder
10
11 ARG FOSSIL_VERSION=2.24
12
13 RUN apt-get update && apt-get install -y --no-install-recommends \
14 build-essential \
15 curl \
16 ca-certificates \
17 zlib1g-dev \
18 libssl-dev \
19 tcl \
20 && rm -rf /var/lib/apt/lists/*
21
22 WORKDIR /build
23
24 RUN curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \
25 -o fossil.tar.gz \
26 && tar xzf fossil.tar.gz \
27 && cd fossil-src-${FOSSIL_VERSION} \
28 && ./configure --prefix=/usr/local --with-openssl=auto --json \
29 && make -j$(nproc) \
30 && make install \
31 && fossil version
32
33 # ── Stage 2: Runtime image ─────────────────────────────────────────────────
34
35 FROM python:3.12-slim AS base
36
37 # Version pins — change these to upgrade
38 ARG LITESTREAM_VERSION=0.3.13
39 ARG CADDY_VERSION=2.9
40
41 # Runtime deps only (no build tools)
42 RUN apt-get update && apt-get install -y --no-install-recommends \
43 zlib1g \
44 libssl3 \
45 curl \
46 ca-certificates \
47 && rm -rf /var/lib/apt/lists/*
48
49 # Copy Fossil binary from builder
50 COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
51
52 # Install Caddy (pinned)
53 RUN curl -sSL "https://caddyserver.com/api/download?os=linux&arch=amd64&version=v${CADDY_VERSION}" \
54 -o /usr/local/bin/caddy \
55 && chmod +x /usr/local/bin/caddy
56
57 # Install Litestream (pinned)
58 RUN curl -sSL "https://github.com/benbjohnson/litestream/releases/download/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-amd64.tar.gz" \
59 | tar -xz -C /usr/local/bin/
60
61 # Verify all binaries
62 RUN fossil version && caddy version && litestream version
63
64 # Create data directories
65 RUN mkdir -p /data/repos /data/trash /etc/caddy
66
67 # Copy configuration files
68 COPY Caddyfile /etc/caddy/Caddyfile
69 COPY litestream.yml /etc/litestream.yml
70
71 # Copy and install the fossilrepo package
72 COPY .. /app
73 WORKDIR /app
74 RUN pip install --no-cache-dir .
75
76 # Expose ports: Caddy HTTPS (443), Caddy HTTP (80), Fossil direct (8080)
77 EXPOSE 80 443 8080
78
79 # Litestream wraps the fossil server process, replicating all .fossil
80 # files to S3 continuously while the server runs.
81 CMD ["litestream", "replicate", "-exec", "caddy run --config /etc/caddy/Caddyfile"]
--- a/docker/docker-compose.fossil.yml
+++ b/docker/docker-compose.fossil.yml
@@ -0,0 +1,32 @@
1
+# fossilrepo local development stack
2
+#
3
+# Run: docker compose up
4
+# Creates a local Fossil server with Caddy routing and Litestream replication.
5
+
6
+services:
7
+ fossilrepo:
8
+ build:
9
+ context: ..
10
+ dockerfile: docker/Dockerfile.fossil
11
+ ports:
12
+ - "80:80"
13
+ - "443:443"
14
+ - "8080:8080"
15
+ volumes:
16
+ - fossil-data:/data/repos
17
+ environment:
18
+ # S3 replication (configure for your bucket)
19
+ - FOSSILREPO_S3_BUCKET=${FOSSILREPO_S3_BUCKET:-}
20
+ - FOSSILREPO_S3_ENDPOINT=${FOSSILREPO_S3_ENDPOINT:-}
21
+ - FOSSILREPO_S3_REGION=${FOSSILREPO_S3_REGION:-us-east-1}
22
+ - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
23
+ - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
24
+ # Server config
25
+ - FOSSILREPO_CADDY_DOMAIN=${FOSSILREPO_CADDY_DOMAIN:-localhost}
26
+ - FOSSILREPO_FOSSIL_PORT=8080
27
+ - FOSSILREPO_DATA_DIR=/data/repos
28
+ restart: unless-stopped
29
+
30
+volumes:
31
+ fossil-data:
32
+ driver: local
--- a/docker/docker-compose.fossil.yml
+++ b/docker/docker-compose.fossil.yml
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/docker-compose.fossil.yml
+++ b/docker/docker-compose.fossil.yml
@@ -0,0 +1,32 @@
1 # fossilrepo local development stack
2 #
3 # Run: docker compose up
4 # Creates a local Fossil server with Caddy routing and Litestream replication.
5
6 services:
7 fossilrepo:
8 build:
9 context: ..
10 dockerfile: docker/Dockerfile.fossil
11 ports:
12 - "80:80"
13 - "443:443"
14 - "8080:8080"
15 volumes:
16 - fossil-data:/data/repos
17 environment:
18 # S3 replication (configure for your bucket)
19 - FOSSILREPO_S3_BUCKET=${FOSSILREPO_S3_BUCKET:-}
20 - FOSSILREPO_S3_ENDPOINT=${FOSSILREPO_S3_ENDPOINT:-}
21 - FOSSILREPO_S3_REGION=${FOSSILREPO_S3_REGION:-us-east-1}
22 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
23 - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
24 # Server config
25 - FOSSILREPO_CADDY_DOMAIN=${FOSSILREPO_CADDY_DOMAIN:-localhost}
26 - FOSSILREPO_FOSSIL_PORT=8080
27 - FOSSILREPO_DATA_DIR=/data/repos
28 restart: unless-stopped
29
30 volumes:
31 fossil-data:
32 driver: local
--- a/docker/litestream.yml
+++ b/docker/litestream.yml
@@ -0,0 +1,18 @@
1
+# Litestream replication configuration
2
+#
3
+# Continuously replicates all .fossil files in /data/repos/ to S3.
4
+# Each .fossil file is a SQLite database — Litestream streams WAL
5
+# changes to S3 for continuous backup and point-in-time recovery.
6
+#
7
+# New .fossil files are picked up automatically when using the
8
+# "dbs" glob pattern below.
9
+
10
+dbs:
11
+ - path: /data/repos/*.fossil
12
+ replicas:
13
+ - type: s3
14
+ bucket: ${FOSSILREPO_S3_BUCKET}
15
+ endpoint: ${FOSSILREPO_S3_ENDPOINT}
16
+ region: ${FOSSILREPO_S3_REGION}
17
+ access-key-id: ${AWS_ACCESS_KEY_ID}
18
+ secret-access-key: ${AWS_SECRET_ACCESS_KEY}
--- a/docker/litestream.yml
+++ b/docker/litestream.yml
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/docker/litestream.yml
+++ b/docker/litestream.yml
@@ -0,0 +1,18 @@
1 # Litestream replication configuration
2 #
3 # Continuously replicates all .fossil files in /data/repos/ to S3.
4 # Each .fossil file is a SQLite database — Litestream streams WAL
5 # changes to S3 for continuous backup and point-in-time recovery.
6 #
7 # New .fossil files are picked up automatically when using the
8 # "dbs" glob pattern below.
9
10 dbs:
11 - path: /data/repos/*.fossil
12 replicas:
13 - type: s3
14 bucket: ${FOSSILREPO_S3_BUCKET}
15 endpoint: ${FOSSILREPO_S3_ENDPOINT}
16 region: ${FOSSILREPO_S3_REGION}
17 access-key-id: ${AWS_ACCESS_KEY_ID}
18 secret-access-key: ${AWS_SECRET_ACCESS_KEY}
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -0,0 +1,29 @@
1
+# Use an official Python runtime as a parent image
2
+FROM python:3.9-slim
3
+
4
+# Set environment variables
5
+ENV PYTHONDONTWRITEBYTECODE 1
6
+ENV PYTHONUNBUFFERED 1
7
+
8
+# Set work directory
9
+WORKDIR /app
10
+
11
+# Install system dependencies
12
+RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ fossil \
14
+ default-mysql-client \
15
+ postgresql-client \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+# Install Python dependencies
19
+COPY requirements.txt /app/
20
+RUN pip install --upgrade pip && pip install -r requirements.txt
21
+
22
+# Copy project
23
+COPY . /app/
24
+
25
+# Expose port
26
+EXPOSE 5000
27
+
28
+# Run the application
29
+CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil-platform/Dockerfile
+++ b/fossil-platform/Dockerfile
@@ -0,0 +1,29 @@
1 # Use an official Python runtime as a parent image
2 FROM python:3.9-slim
3
4 # Set environment variables
5 ENV PYTHONDONTWRITEBYTECODE 1
6 ENV PYTHONUNBUFFERED 1
7
8 # Set work directory
9 WORKDIR /app
10
11 # Install system dependencies
12 RUN apt-get update && apt-get install -y --no-install-recommends \
13 fossil \
14 default-mysql-client \
15 postgresql-client \
16 && rm -rf /var/lib/apt/lists/*
17
18 # Install Python dependencies
19 COPY requirements.txt /app/
20 RUN pip install --upgrade pip && pip install -r requirements.txt
21
22 # Copy project
23 COPY . /app/
24
25 # Expose port
26 EXPOSE 5000
27
28 # Run the application
29 CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -0,0 +1,65 @@
1
+# Fossil SCM-based GitHub/GitLab-like Platform
2
+
3
+This project aims to create a GitHub/GitLab-like platform based on Fossil SCM, providing a comprehensive solution for repository management, issue tracking, wikis, user management, and repository analytics.
4
+
5
+## Core Features
6
+
7
+- Version Control: Utilizes Fossil SCM for repository management
8
+- Backend: Flask-based API for interacting with Fossil SCM and managing platform features
9
+- Database: Supports both MySQL and PostgreSQL via feature flags
10
+- Frontend: React-based web interface for viewing repositories, commits, issues, wikis, and user management
11
+- Authentication & Permissions: OAuth (Google, GitHub) and custom JWT-based authentication
12
+- CI/CD Integration: Optional continuous integration (feature flag enabled)
13
+- Notification System: Email notifications and real-time WebSocket updates
14
+- Extensibility: Plugin system for additional features
15
+
16
+## Technical Stack
17
+
18
+- Backend: Flask (Python)
19
+- Frontend: React (JavaScript)
20
+- Database: MySQL/PostgreSQL (configurable via feature flags)
21
+- ORM: SQLAlchemy
22
+- Authentication: OAuth 2.0, JWT
23
+- Real-time Updates: WebSockets
24
+- CI/CD: (Optional, configurable)
25
+
26
+## Feature Flags
27
+
28
+The platform uses feature flags to enable/disable certain functionalities:
29
+
30
+- `DB_TYPE`: Toggle between MySQL and PostgreSQL (e.g., `DB_TYPE=mysql` or `DB_TYPE=postgres`)
31
+- `ENABLE_CICD`: Enable/disable CI/CD integration (e.g., `ENABLE_CICD=true`)
32
+- `ENABLE_NOTIFICATIONS`: Enable/disable real-time WebSocket updates and email notifications (e.g., `ENABLE_NOTIFICATIONS=true`)
33
+- `AUTH_TYPE`: Choose between OAuth-based login or JWT (e.g., `AUTH_TYPE=oauth` or `AUTH_TYPE=jwt`)
34
+
35
+## Getting Started
36
+
37
+1. Clone the repository
38
+2. Set up the backend:
39
+ - Install Python dependencies: `pip install -r requirements.txt`
40
+ - Configure environment variables for feature flags
41
+ - Run the Flask server: `python app.py`
42
+3. Set up the frontend:
43
+ - Navigate to the frontend directory: `cd frontend`
44
+ - Install npm packages: `npm install`
45
+ - Start the React app: `npm start`
46
+
47
+## Development Roadmap
48
+
49
+1. Set up Fossil SCM integration
50
+2. Implement database abstraction with SQLAlchemy
51
+3. Develop core Flask backend API
52
+4. Create basic React frontend
53
+5. Implement authentication and authorization
54
+6. Add notification system
55
+7. Develop plugin system for extensibility
56
+8. Implement CI/CD integration
57
+9. Comprehensive testing and documentation
58
+
59
+## Contributing
60
+
61
+Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
62
+
63
+## License
64
+
65
+This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil-platform/README.md
+++ b/fossil-platform/README.md
@@ -0,0 +1,65 @@
1 # Fossil SCM-based GitHub/GitLab-like Platform
2
3 This project aims to create a GitHub/GitLab-like platform based on Fossil SCM, providing a comprehensive solution for repository management, issue tracking, wikis, user management, and repository analytics.
4
5 ## Core Features
6
7 - Version Control: Utilizes Fossil SCM for repository management
8 - Backend: Flask-based API for interacting with Fossil SCM and managing platform features
9 - Database: Supports both MySQL and PostgreSQL via feature flags
10 - Frontend: React-based web interface for viewing repositories, commits, issues, wikis, and user management
11 - Authentication & Permissions: OAuth (Google, GitHub) and custom JWT-based authentication
12 - CI/CD Integration: Optional continuous integration (feature flag enabled)
13 - Notification System: Email notifications and real-time WebSocket updates
14 - Extensibility: Plugin system for additional features
15
16 ## Technical Stack
17
18 - Backend: Flask (Python)
19 - Frontend: React (JavaScript)
20 - Database: MySQL/PostgreSQL (configurable via feature flags)
21 - ORM: SQLAlchemy
22 - Authentication: OAuth 2.0, JWT
23 - Real-time Updates: WebSockets
24 - CI/CD: (Optional, configurable)
25
26 ## Feature Flags
27
28 The platform uses feature flags to enable/disable certain functionalities:
29
30 - `DB_TYPE`: Toggle between MySQL and PostgreSQL (e.g., `DB_TYPE=mysql` or `DB_TYPE=postgres`)
31 - `ENABLE_CICD`: Enable/disable CI/CD integration (e.g., `ENABLE_CICD=true`)
32 - `ENABLE_NOTIFICATIONS`: Enable/disable real-time WebSocket updates and email notifications (e.g., `ENABLE_NOTIFICATIONS=true`)
33 - `AUTH_TYPE`: Choose between OAuth-based login or JWT (e.g., `AUTH_TYPE=oauth` or `AUTH_TYPE=jwt`)
34
35 ## Getting Started
36
37 1. Clone the repository
38 2. Set up the backend:
39 - Install Python dependencies: `pip install -r requirements.txt`
40 - Configure environment variables for feature flags
41 - Run the Flask server: `python app.py`
42 3. Set up the frontend:
43 - Navigate to the frontend directory: `cd frontend`
44 - Install npm packages: `npm install`
45 - Start the React app: `npm start`
46
47 ## Development Roadmap
48
49 1. Set up Fossil SCM integration
50 2. Implement database abstraction with SQLAlchemy
51 3. Develop core Flask backend API
52 4. Create basic React frontend
53 5. Implement authentication and authorization
54 6. Add notification system
55 7. Develop plugin system for extensibility
56 8. Implement CI/CD integration
57 9. Comprehensive testing and documentation
58
59 ## Contributing
60
61 Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
62
63 ## License
64
65 This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.

No diff available

--- a/fossil/admin.py
+++ b/fossil/admin.py
@@ -0,0 +1,25 @@
1
+from django.contrib import admin
2
+
3
+from core.admin import BaseCoreAdmin
4
+
5
+from .rum import ForumPoste(admin.TabularInline):
6
+ model = FossilSnapshot
7
+ extra = 0
8
+ readonly_fields = ("file", "file_size_bytes", "fossil_hash")
9
+
10
+
11
+@admin.register(FossilRepository)
12
+class FossilRepositoryAdmin(BaseCoreAdmin):
13
+ list_display = ("filename", "project", "file_size_bytes", "checkin_count", "last_checkin_at")
14
+ search_fields = ("filename", "project__name")
15
+ raw_id_fields = ("project",)
16
+ inlines = [FossilSnapshotInline]
17
+
18
+
19
+@admin.register(FossilSnapshot)
20
+class FossilSnapshotAdmin(BaseCoreAdmin):
21
+ list_display = ("repository", "file_size_bytes", "fossil_hash", "created_at")
22
+ raw_id_fields = ("repository",)
23
+
24
+
25
+class SyncLogInline(admin
--- a/fossil/admin.py
+++ b/fossil/admin.py
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/admin.py
+++ b/fossil/admin.py
@@ -0,0 +1,25 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .rum import ForumPoste(admin.TabularInline):
6 model = FossilSnapshot
7 extra = 0
8 readonly_fields = ("file", "file_size_bytes", "fossil_hash")
9
10
11 @admin.register(FossilRepository)
12 class FossilRepositoryAdmin(BaseCoreAdmin):
13 list_display = ("filename", "project", "file_size_bytes", "checkin_count", "last_checkin_at")
14 search_fields = ("filename", "project__name")
15 raw_id_fields = ("project",)
16 inlines = [FossilSnapshotInline]
17
18
19 @admin.register(FossilSnapshot)
20 class FossilSnapshotAdmin(BaseCoreAdmin):
21 list_display = ("repository", "file_size_bytes", "fossil_hash", "created_at")
22 raw_id_fields = ("repository",)
23
24
25 class SyncLogInline(admin
--- a/fossil/apps.py
+++ b/fossil/apps.py
@@ -0,0 +1,9 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class FossilConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "fossil"
7
+
8
+ def ready(self):
9
+ import fossil.signals # noqa: F401
--- a/fossil/apps.py
+++ b/fossil/apps.py
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
--- a/fossil/apps.py
+++ b/fossil/apps.py
@@ -0,0 +1,9 @@
1 from django.apps import AppConfig
2
3
4 class FossilConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "fossil"
7
8 def ready(self):
9 import fossil.signals # noqa: F401
--- a/fossil/cli.py
+++ b/fossil/cli.py
@@ -0,0 +1,83 @@
1
+"""Thin wrapper around the fossil binary for write operations."""
2
+
3
+import , datetime"Thin wrapper around the fossil binary for write operations."""
4
+
5
+import logging
6
+import , datetimeimport os
7
+import subprocess
8
+from pathlib import Path
9
+
10
+logger = logging.getLogger(__name__)
11
+
12
+
13
+class FossilCLI:
14
+ """Wrapper around the fossil binary for write operations."""
15
+
16
+ def __init__(self, binary: str | No
17
+
18
+ binary = config.FOSSIL_BINARY_PATH
19
+ self.binary = binary
20
+
21
+ @property
22
+ def _env(self):
23
+ import os
24
+
25
+ return {**os.environ, "USER": "fossilrepo"}
26
+
27
+ def _run(self, *ar)result = subproces: Path, name: str) -> bytes:
28
+ """Get unversioned file content: fossil uv cat <name> -R <repo>.
29
+
30
+ Returns raw bytes. Raises FileNotFoundError if the file doesn't exist
31
+ or the command fails.
32
+ """
33
+ cmd = [self.binary, "uv", "cat", name, "-R", str(repo_path)]
34
+ result = subprocess.run(cmd, capture_output=True, timeout=60, env=self._env)
35
+ if result.returncode != 0:
36
+ raise FileNotFoundError(f"Unversioned file not found: {name}")
37
+ return result.stdout
38
+
39
+ def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
40
+ """Export Fossil repo to a Git mirror directory. Incremental.
41
+
42
+ When auth_token is provided, credentials are passed via Git environment
43
+ variables instead of being embedded in the URL (avoids exposure in
44
+ process args and command output).
45
+
46
+ Returns {success, message}.
47
+ """
48
+ mirror_dir.mkdir(parents=True, exist_ok=True)
49
+ cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
50
+
51
+ env = dict(self._env)
52
+
53
+ temp_paths = []
54
+ if autopush_url:
55
+ cmd.extend(["--autopush", autopush_url])
56
+ if auth_token:
57
+ env["GIT_TERMINAL_PROMPT"] = "0"
58
+ # Use a temporary askpass script instead of a shell credential
59
+ # helper to avoid command injection via token metacharacters.
60
+ # The token is stored in a separate file so it never appears
61
+ # in shell syntaxt tempfile
62
+
63
+ token_fd, token_path = tempfile.mkstemp(suffix=".tok")
64
+ with os.fdopen(token_fd, "w") as f:
65
+ f.writeenv["GIT_CONFIG_COUNT"] = "1"se, "message": "Export timse, "message": "ExportE_0"] = f"! capture_ouHTTP), fall back to nlink(p)
66
+
67
+ def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
68
+ """Generate an SSH key pair for Git authentication.
69
+
70
+ Returns {success, public_key, fingerprint}.
71
+ """
72
+ import os
73
+
74
+ try:
75
+ via CGI mode keE_0"] = f"!
76
+
77
+ def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
78
+ """Export Fossil repo to a Git mirror directory. Incremental.
79
+
80
+ When auth_token is provided, credentials are passed via Git environment
81
+ variables instead of being embedded in the URL (avoids exposure in
82
+ process args and command output).
83
+E_0"] = f"!
--- a/fossil/cli.py
+++ b/fossil/cli.py
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/cli.py
+++ b/fossil/cli.py
@@ -0,0 +1,83 @@
1 """Thin wrapper around the fossil binary for write operations."""
2
3 import , datetime"Thin wrapper around the fossil binary for write operations."""
4
5 import logging
6 import , datetimeimport os
7 import subprocess
8 from pathlib import Path
9
10 logger = logging.getLogger(__name__)
11
12
13 class FossilCLI:
14 """Wrapper around the fossil binary for write operations."""
15
16 def __init__(self, binary: str | No
17
18 binary = config.FOSSIL_BINARY_PATH
19 self.binary = binary
20
21 @property
22 def _env(self):
23 import os
24
25 return {**os.environ, "USER": "fossilrepo"}
26
27 def _run(self, *ar)result = subproces: Path, name: str) -> bytes:
28 """Get unversioned file content: fossil uv cat <name> -R <repo>.
29
30 Returns raw bytes. Raises FileNotFoundError if the file doesn't exist
31 or the command fails.
32 """
33 cmd = [self.binary, "uv", "cat", name, "-R", str(repo_path)]
34 result = subprocess.run(cmd, capture_output=True, timeout=60, env=self._env)
35 if result.returncode != 0:
36 raise FileNotFoundError(f"Unversioned file not found: {name}")
37 return result.stdout
38
39 def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
40 """Export Fossil repo to a Git mirror directory. Incremental.
41
42 When auth_token is provided, credentials are passed via Git environment
43 variables instead of being embedded in the URL (avoids exposure in
44 process args and command output).
45
46 Returns {success, message}.
47 """
48 mirror_dir.mkdir(parents=True, exist_ok=True)
49 cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
50
51 env = dict(self._env)
52
53 temp_paths = []
54 if autopush_url:
55 cmd.extend(["--autopush", autopush_url])
56 if auth_token:
57 env["GIT_TERMINAL_PROMPT"] = "0"
58 # Use a temporary askpass script instead of a shell credential
59 # helper to avoid command injection via token metacharacters.
60 # The token is stored in a separate file so it never appears
61 # in shell syntaxt tempfile
62
63 token_fd, token_path = tempfile.mkstemp(suffix=".tok")
64 with os.fdopen(token_fd, "w") as f:
65 f.writeenv["GIT_CONFIG_COUNT"] = "1"se, "message": "Export timse, "message": "ExportE_0"] = f"! capture_ouHTTP), fall back to nlink(p)
66
67 def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
68 """Generate an SSH key pair for Git authentication.
69
70 Returns {success, public_key, fingerprint}.
71 """
72 import os
73
74 try:
75 via CGI mode keE_0"] = f"!
76
77 def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
78 """Export Fossil repo to a Git mirror directory. Incremental.
79
80 When auth_token is provided, credentials are passed via Git environment
81 variables instead of being embedded in the URL (avoids exposure in
82 process args and command output).
83 E_0"] = f"!
--- a/fossil/migrations/0001_initial.py
+++ b/fossil/migrations/0001_initial.py
@@ -0,0 +1,363 @@
1
+# Generated by Django 5.2.12 on 2026-04-06 02:07
2
+
3
+import django.db.models.deletion
4
+import simple_history.models
5
+from django.conf import settings
6
+from django.db import migrations, models
7
+
8
+
9
+class Migration(migrations.Migration):
10
+ initial = True
11
+
12
+ dependencies = [
13
+ ("projects", "0001_initial"),
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="FossilRepository",
20
+ fields=[
21
+ (
22
+ "id",
23
+ models.BigAutoField(
24
+ auto_created=True,
25
+ primary_key=True,
26
+ serialize=False,
27
+ verbose_name="ID",
28
+ ),
29
+ ),
30
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
31
+ ("created_at", models.DateTimeField(auto_now_add=True)),
32
+ ("updated_at", models.DateTimeField(auto_now=True)),
33
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
34
+ (
35
+ "filename",
36
+ models.CharField(
37
+ help_text="Filename relative to FOSSIL_DATA_DIR",
38
+ max_length=255,
39
+ unique=True,
40
+ ),
41
+ ),
42
+ ("file_size_bytes", models.BigIntegerField(default=0)),
43
+ (
44
+ "fossil_project_code",
45
+ models.CharField(blank=True, default="", max_length=40),
46
+ ),
47
+ ("last_checkin_at", models.DateTimeField(blank=True, null=True)),
48
+ ("checkin_count", models.PositiveIntegerField(default=0)),
49
+ ("s3_key", models.CharField(blank=True, default="", max_length=500)),
50
+ ("s3_last_replicated_at", models.DateTimeField(blank=True, null=True)),
51
+ (
52
+ "created_by",
53
+ models.ForeignKey(
54
+ blank=True,
55
+ null=True,
56
+ on_delete=django.db.models.deletion.SET_NULL,
57
+ related_name="+",
58
+ to=settings.AUTH_USER_MODEL,
59
+ ),
60
+ ),
61
+ (
62
+ "deleted_by",
63
+ models.ForeignKey(
64
+ blank=True,
65
+ null=True,
66
+ on_delete=django.db.models.deletion.SET_NULL,
67
+ related_name="+",
68
+ to=settings.AUTH_USER_MODEL,
69
+ ),
70
+ ),
71
+ (
72
+ "project",
73
+ models.OneToOneField(
74
+ on_delete=django.db.models.deletion.CASCADE,
75
+ related_name="fossil_repo",
76
+ to="projects.project",
77
+ ),
78
+ ),
79
+ (
80
+ "updated_by",
81
+ models.ForeignKey(
82
+ blank=True,
83
+ null=True,
84
+ on_delete=django.db.models.deletion.SET_NULL,
85
+ related_name="+",
86
+ to=settings.AUTH_USER_MODEL,
87
+ ),
88
+ ),
89
+ ],
90
+ options={
91
+ "verbose_name": "Fossil Repository",
92
+ "verbose_name_plural": "Fossil Repositories",
93
+ "ordering": ["filename"],
94
+ },
95
+ ),
96
+ migrations.CreateModel(
97
+ name="FossilSnapshot",
98
+ fields=[
99
+ (
100
+ "id",
101
+ models.BigAutoField(
102
+ auto_created=True,
103
+ primary_key=True,
104
+ serialize=False,
105
+ verbose_name="ID",
106
+ ),
107
+ ),
108
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
109
+ ("created_at", models.DateTimeField(auto_now_add=True)),
110
+ ("updated_at", models.DateTimeField(auto_now=True)),
111
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
112
+ ("file", models.FileField(upload_to="fossil_snapshots/%Y/%m/")),
113
+ ("file_size_bytes", models.BigIntegerField(default=0)),
114
+ (
115
+ "fossil_hash",
116
+ models.CharField(
117
+ blank=True,
118
+ default="",
119
+ help_text="SHA-256 of the .fossil file",
120
+ max_length=64,
121
+ ),
122
+ ),
123
+ ("note", models.CharField(blank=True, default="", max_length=200)),
124
+ (
125
+ "created_by",
126
+ models.ForeignKey(
127
+ blank=True,
128
+ null=True,
129
+ on_delete=django.db.models.deletion.SET_NULL,
130
+ related_name="+",
131
+ to=settings.AUTH_USER_MODEL,
132
+ ),
133
+ ),
134
+ (
135
+ "deleted_by",
136
+ models.ForeignKey(
137
+ blank=True,
138
+ null=True,
139
+ on_delete=django.db.models.deletion.SET_NULL,
140
+ related_name="+",
141
+ to=settings.AUTH_USER_MODEL,
142
+ ),
143
+ ),
144
+ (
145
+ "repository",
146
+ models.ForeignKey(
147
+ on_delete=django.db.models.deletion.CASCADE,
148
+ related_name="snapshots",
149
+ to="fossil.fossilrepository",
150
+ ),
151
+ ),
152
+ (
153
+ "updated_by",
154
+ models.ForeignKey(
155
+ blank=True,
156
+ null=True,
157
+ on_delete=django.db.models.deletion.SET_NULL,
158
+ related_name="+",
159
+ to=settings.AUTH_USER_MODEL,
160
+ ),
161
+ ),
162
+ ],
163
+ options={
164
+ "ordering": ["-created_at"],
165
+ "get_latest_by": "created_at",
166
+ },
167
+ ),
168
+ migrations.CreateModel(
169
+ name="HistoricalFossilRepository",
170
+ fields=[
171
+ (
172
+ "id",
173
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
174
+ ),
175
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
176
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
177
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
178
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
179
+ (
180
+ "filename",
181
+ models.CharField(
182
+ db_index=True,
183
+ help_text="Filename relative to FOSSIL_DATA_DIR",
184
+ max_length=255,
185
+ ),
186
+ ),
187
+ ("file_size_bytes", models.BigIntegerField(default=0)),
188
+ (
189
+ "fossil_project_code",
190
+ models.CharField(blank=True, default="", max_length=40),
191
+ ),
192
+ ("last_checkin_at", models.DateTimeField(blank=True, null=True)),
193
+ ("checkin_count", models.PositiveIntegerField(default=0)),
194
+ ("s3_key", models.CharField(blank=True, default="", max_length=500)),
195
+ ("s3_last_replicated_at", models.DateTimeField(blank=True, null=True)),
196
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
197
+ ("history_date", models.DateTimeField(db_index=True)),
198
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
199
+ (
200
+ "history_type",
201
+ models.CharField(
202
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
203
+ max_length=1,
204
+ ),
205
+ ),
206
+ (
207
+ "created_by",
208
+ models.ForeignKey(
209
+ blank=True,
210
+ db_constraint=False,
211
+ null=True,
212
+ on_delete=django.db.models.deletion.DO_NOTHING,
213
+ related_name="+",
214
+ to=settings.AUTH_USER_MODEL,
215
+ ),
216
+ ),
217
+ (
218
+ "deleted_by",
219
+ models.ForeignKey(
220
+ blank=True,
221
+ db_constraint=False,
222
+ null=True,
223
+ on_delete=django.db.models.deletion.DO_NOTHING,
224
+ related_name="+",
225
+ to=settings.AUTH_USER_MODEL,
226
+ ),
227
+ ),
228
+ (
229
+ "history_user",
230
+ models.ForeignKey(
231
+ null=True,
232
+ on_delete=django.db.models.deletion.SET_NULL,
233
+ related_name="+",
234
+ to=settings.AUTH_USER_MODEL,
235
+ ),
236
+ ),
237
+ (
238
+ "project",
239
+ models.ForeignKey(
240
+ blank=True,
241
+ db_constraint=False,
242
+ null=True,
243
+ on_delete=django.db.models.deletion.DO_NOTHING,
244
+ related_name="+",
245
+ to="projects.project",
246
+ ),
247
+ ),
248
+ (
249
+ "updated_by",
250
+ models.ForeignKey(
251
+ blank=True,
252
+ db_constraint=False,
253
+ null=True,
254
+ on_delete=django.db.models.deletion.DO_NOTHING,
255
+ related_name="+",
256
+ to=settings.AUTH_USER_MODEL,
257
+ ),
258
+ ),
259
+ ],
260
+ options={
261
+ "verbose_name": "historical Fossil Repository",
262
+ "verbose_name_plural": "historical Fossil Repositories",
263
+ "ordering": ("-history_date", "-history_id"),
264
+ "get_latest_by": ("history_date", "history_id"),
265
+ },
266
+ bases=(simple_history.models.HistoricalChanges, models.Model),
267
+ ),
268
+ migrations.CreateModel(
269
+ name="HistoricalFossilSnapshot",
270
+ fields=[
271
+ (
272
+ "id",
273
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
274
+ ),
275
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
276
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
277
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
278
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
279
+ ("file", models.TextField(max_length=100)),
280
+ ("file_size_bytes", models.BigIntegerField(default=0)),
281
+ (
282
+ "fossil_hash",
283
+ models.CharField(
284
+ blank=True,
285
+ default="",
286
+ help_text="SHA-256 of the .fossil file",
287
+ max_length=64,
288
+ ),
289
+ ),
290
+ ("note", models.CharField(blank=True, default="", max_length=200)),
291
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
292
+ ("history_date", models.DateTimeField(db_index=True)),
293
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
294
+ (
295
+ "history_type",
296
+ models.CharField(
297
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
298
+ max_length=1,
299
+ ),
300
+ ),
301
+ (
302
+ "created_by",
303
+ models.ForeignKey(
304
+ blank=True,
305
+ db_constraint=False,
306
+ null=True,
307
+ on_delete=django.db.models.deletion.DO_NOTHING,
308
+ related_name="+",
309
+ to=settings.AUTH_USER_MODEL,
310
+ ),
311
+ ),
312
+ (
313
+ "deleted_by",
314
+ models.ForeignKey(
315
+ blank=True,
316
+ db_constraint=False,
317
+ null=True,
318
+ on_delete=django.db.models.deletion.DO_NOTHING,
319
+ related_name="+",
320
+ to=settings.AUTH_USER_MODEL,
321
+ ),
322
+ ),
323
+ (
324
+ "history_user",
325
+ models.ForeignKey(
326
+ null=True,
327
+ on_delete=django.db.models.deletion.SET_NULL,
328
+ related_name="+",
329
+ to=settings.AUTH_USER_MODEL,
330
+ ),
331
+ ),
332
+ (
333
+ "repository",
334
+ models.ForeignKey(
335
+ blank=True,
336
+ db_constraint=False,
337
+ null=True,
338
+ on_delete=django.db.models.deletion.DO_NOTHING,
339
+ related_name="+",
340
+ to="fossil.fossilrepository",
341
+ ),
342
+ ),
343
+ (
344
+ "updated_by",
345
+ models.ForeignKey(
346
+ blank=True,
347
+ db_constraint=False,
348
+ null=True,
349
+ on_delete=django.db.models.deletion.DO_NOTHING,
350
+ related_name="+",
351
+ to=settings.AUTH_USER_MODEL,
352
+ ),
353
+ ),
354
+ ],
355
+ options={
356
+ "verbose_name": "historical fossil snapshot",
357
+ "verbose_name_plural": "historical fossil snapshots",
358
+ "ordering": ("-history_date", "-history_id"),
359
+ "get_latest_by": ("history_date", "history_id"),
360
+ },
361
+ bases=(simple_history.models.HistoricalChanges, models.Model),
362
+ ),
363
+ ]
--- a/fossil/migrations/0001_initial.py
+++ b/fossil/migrations/0001_initial.py
@@ -0,0 +1,363 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0001_initial.py
+++ b/fossil/migrations/0001_initial.py
@@ -0,0 +1,363 @@
1 # Generated by Django 5.2.12 on 2026-04-06 02:07
2
3 import django.db.models.deletion
4 import simple_history.models
5 from django.conf import settings
6 from django.db import migrations, models
7
8
9 class Migration(migrations.Migration):
10 initial = True
11
12 dependencies = [
13 ("projects", "0001_initial"),
14 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 ]
16
17 operations = [
18 migrations.CreateModel(
19 name="FossilRepository",
20 fields=[
21 (
22 "id",
23 models.BigAutoField(
24 auto_created=True,
25 primary_key=True,
26 serialize=False,
27 verbose_name="ID",
28 ),
29 ),
30 ("version", models.PositiveIntegerField(default=1, editable=False)),
31 ("created_at", models.DateTimeField(auto_now_add=True)),
32 ("updated_at", models.DateTimeField(auto_now=True)),
33 ("deleted_at", models.DateTimeField(blank=True, null=True)),
34 (
35 "filename",
36 models.CharField(
37 help_text="Filename relative to FOSSIL_DATA_DIR",
38 max_length=255,
39 unique=True,
40 ),
41 ),
42 ("file_size_bytes", models.BigIntegerField(default=0)),
43 (
44 "fossil_project_code",
45 models.CharField(blank=True, default="", max_length=40),
46 ),
47 ("last_checkin_at", models.DateTimeField(blank=True, null=True)),
48 ("checkin_count", models.PositiveIntegerField(default=0)),
49 ("s3_key", models.CharField(blank=True, default="", max_length=500)),
50 ("s3_last_replicated_at", models.DateTimeField(blank=True, null=True)),
51 (
52 "created_by",
53 models.ForeignKey(
54 blank=True,
55 null=True,
56 on_delete=django.db.models.deletion.SET_NULL,
57 related_name="+",
58 to=settings.AUTH_USER_MODEL,
59 ),
60 ),
61 (
62 "deleted_by",
63 models.ForeignKey(
64 blank=True,
65 null=True,
66 on_delete=django.db.models.deletion.SET_NULL,
67 related_name="+",
68 to=settings.AUTH_USER_MODEL,
69 ),
70 ),
71 (
72 "project",
73 models.OneToOneField(
74 on_delete=django.db.models.deletion.CASCADE,
75 related_name="fossil_repo",
76 to="projects.project",
77 ),
78 ),
79 (
80 "updated_by",
81 models.ForeignKey(
82 blank=True,
83 null=True,
84 on_delete=django.db.models.deletion.SET_NULL,
85 related_name="+",
86 to=settings.AUTH_USER_MODEL,
87 ),
88 ),
89 ],
90 options={
91 "verbose_name": "Fossil Repository",
92 "verbose_name_plural": "Fossil Repositories",
93 "ordering": ["filename"],
94 },
95 ),
96 migrations.CreateModel(
97 name="FossilSnapshot",
98 fields=[
99 (
100 "id",
101 models.BigAutoField(
102 auto_created=True,
103 primary_key=True,
104 serialize=False,
105 verbose_name="ID",
106 ),
107 ),
108 ("version", models.PositiveIntegerField(default=1, editable=False)),
109 ("created_at", models.DateTimeField(auto_now_add=True)),
110 ("updated_at", models.DateTimeField(auto_now=True)),
111 ("deleted_at", models.DateTimeField(blank=True, null=True)),
112 ("file", models.FileField(upload_to="fossil_snapshots/%Y/%m/")),
113 ("file_size_bytes", models.BigIntegerField(default=0)),
114 (
115 "fossil_hash",
116 models.CharField(
117 blank=True,
118 default="",
119 help_text="SHA-256 of the .fossil file",
120 max_length=64,
121 ),
122 ),
123 ("note", models.CharField(blank=True, default="", max_length=200)),
124 (
125 "created_by",
126 models.ForeignKey(
127 blank=True,
128 null=True,
129 on_delete=django.db.models.deletion.SET_NULL,
130 related_name="+",
131 to=settings.AUTH_USER_MODEL,
132 ),
133 ),
134 (
135 "deleted_by",
136 models.ForeignKey(
137 blank=True,
138 null=True,
139 on_delete=django.db.models.deletion.SET_NULL,
140 related_name="+",
141 to=settings.AUTH_USER_MODEL,
142 ),
143 ),
144 (
145 "repository",
146 models.ForeignKey(
147 on_delete=django.db.models.deletion.CASCADE,
148 related_name="snapshots",
149 to="fossil.fossilrepository",
150 ),
151 ),
152 (
153 "updated_by",
154 models.ForeignKey(
155 blank=True,
156 null=True,
157 on_delete=django.db.models.deletion.SET_NULL,
158 related_name="+",
159 to=settings.AUTH_USER_MODEL,
160 ),
161 ),
162 ],
163 options={
164 "ordering": ["-created_at"],
165 "get_latest_by": "created_at",
166 },
167 ),
168 migrations.CreateModel(
169 name="HistoricalFossilRepository",
170 fields=[
171 (
172 "id",
173 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
174 ),
175 ("version", models.PositiveIntegerField(default=1, editable=False)),
176 ("created_at", models.DateTimeField(blank=True, editable=False)),
177 ("updated_at", models.DateTimeField(blank=True, editable=False)),
178 ("deleted_at", models.DateTimeField(blank=True, null=True)),
179 (
180 "filename",
181 models.CharField(
182 db_index=True,
183 help_text="Filename relative to FOSSIL_DATA_DIR",
184 max_length=255,
185 ),
186 ),
187 ("file_size_bytes", models.BigIntegerField(default=0)),
188 (
189 "fossil_project_code",
190 models.CharField(blank=True, default="", max_length=40),
191 ),
192 ("last_checkin_at", models.DateTimeField(blank=True, null=True)),
193 ("checkin_count", models.PositiveIntegerField(default=0)),
194 ("s3_key", models.CharField(blank=True, default="", max_length=500)),
195 ("s3_last_replicated_at", models.DateTimeField(blank=True, null=True)),
196 ("history_id", models.AutoField(primary_key=True, serialize=False)),
197 ("history_date", models.DateTimeField(db_index=True)),
198 ("history_change_reason", models.CharField(max_length=100, null=True)),
199 (
200 "history_type",
201 models.CharField(
202 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
203 max_length=1,
204 ),
205 ),
206 (
207 "created_by",
208 models.ForeignKey(
209 blank=True,
210 db_constraint=False,
211 null=True,
212 on_delete=django.db.models.deletion.DO_NOTHING,
213 related_name="+",
214 to=settings.AUTH_USER_MODEL,
215 ),
216 ),
217 (
218 "deleted_by",
219 models.ForeignKey(
220 blank=True,
221 db_constraint=False,
222 null=True,
223 on_delete=django.db.models.deletion.DO_NOTHING,
224 related_name="+",
225 to=settings.AUTH_USER_MODEL,
226 ),
227 ),
228 (
229 "history_user",
230 models.ForeignKey(
231 null=True,
232 on_delete=django.db.models.deletion.SET_NULL,
233 related_name="+",
234 to=settings.AUTH_USER_MODEL,
235 ),
236 ),
237 (
238 "project",
239 models.ForeignKey(
240 blank=True,
241 db_constraint=False,
242 null=True,
243 on_delete=django.db.models.deletion.DO_NOTHING,
244 related_name="+",
245 to="projects.project",
246 ),
247 ),
248 (
249 "updated_by",
250 models.ForeignKey(
251 blank=True,
252 db_constraint=False,
253 null=True,
254 on_delete=django.db.models.deletion.DO_NOTHING,
255 related_name="+",
256 to=settings.AUTH_USER_MODEL,
257 ),
258 ),
259 ],
260 options={
261 "verbose_name": "historical Fossil Repository",
262 "verbose_name_plural": "historical Fossil Repositories",
263 "ordering": ("-history_date", "-history_id"),
264 "get_latest_by": ("history_date", "history_id"),
265 },
266 bases=(simple_history.models.HistoricalChanges, models.Model),
267 ),
268 migrations.CreateModel(
269 name="HistoricalFossilSnapshot",
270 fields=[
271 (
272 "id",
273 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
274 ),
275 ("version", models.PositiveIntegerField(default=1, editable=False)),
276 ("created_at", models.DateTimeField(blank=True, editable=False)),
277 ("updated_at", models.DateTimeField(blank=True, editable=False)),
278 ("deleted_at", models.DateTimeField(blank=True, null=True)),
279 ("file", models.TextField(max_length=100)),
280 ("file_size_bytes", models.BigIntegerField(default=0)),
281 (
282 "fossil_hash",
283 models.CharField(
284 blank=True,
285 default="",
286 help_text="SHA-256 of the .fossil file",
287 max_length=64,
288 ),
289 ),
290 ("note", models.CharField(blank=True, default="", max_length=200)),
291 ("history_id", models.AutoField(primary_key=True, serialize=False)),
292 ("history_date", models.DateTimeField(db_index=True)),
293 ("history_change_reason", models.CharField(max_length=100, null=True)),
294 (
295 "history_type",
296 models.CharField(
297 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
298 max_length=1,
299 ),
300 ),
301 (
302 "created_by",
303 models.ForeignKey(
304 blank=True,
305 db_constraint=False,
306 null=True,
307 on_delete=django.db.models.deletion.DO_NOTHING,
308 related_name="+",
309 to=settings.AUTH_USER_MODEL,
310 ),
311 ),
312 (
313 "deleted_by",
314 models.ForeignKey(
315 blank=True,
316 db_constraint=False,
317 null=True,
318 on_delete=django.db.models.deletion.DO_NOTHING,
319 related_name="+",
320 to=settings.AUTH_USER_MODEL,
321 ),
322 ),
323 (
324 "history_user",
325 models.ForeignKey(
326 null=True,
327 on_delete=django.db.models.deletion.SET_NULL,
328 related_name="+",
329 to=settings.AUTH_USER_MODEL,
330 ),
331 ),
332 (
333 "repository",
334 models.ForeignKey(
335 blank=True,
336 db_constraint=False,
337 null=True,
338 on_delete=django.db.models.deletion.DO_NOTHING,
339 related_name="+",
340 to="fossil.fossilrepository",
341 ),
342 ),
343 (
344 "updated_by",
345 models.ForeignKey(
346 blank=True,
347 db_constraint=False,
348 null=True,
349 on_delete=django.db.models.deletion.DO_NOTHING,
350 related_name="+",
351 to=settings.AUTH_USER_MODEL,
352 ),
353 ),
354 ],
355 options={
356 "verbose_name": "historical fossil snapshot",
357 "verbose_name_plural": "historical fossil snapshots",
358 "ordering": ("-history_date", "-history_id"),
359 "get_latest_by": ("history_date", "history_id"),
360 },
361 bases=(simple_history.models.HistoricalChanges, models.Model),
362 ),
363 ]
--- a/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py
+++ b/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py
@@ -0,0 +1,42 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 00:49
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("fossil", "0001_initial"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="fossilrepository",
14
+ name="last_sync_at",
15
+ field=models.DateTimeField(blank=True, null=True),
16
+ ),
17
+ migrations.AddField(
18
+ model_name="fossilrepository",
19
+ name="remote_url",
20
+ field=models.URLField(blank=True, default="", help_text="Upstream remote URL for sync"),
21
+ ),
22
+ migrations.AddField(
23
+ model_name="fossilrepository",
24
+ name="upstream_artifacts_available",
25
+ field=models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream"),
26
+ ),
27
+ migrations.AddField(
28
+ model_name="historicalfossilrepository",
29
+ name="last_sync_at",
30
+ field=models.DateTimeField(blank=True, null=True),
31
+ ),
32
+ migrations.AddField(
33
+ model_name="historicalfossilrepository",
34
+ name="remote_url",
35
+ field=models.URLField(blank=True, default="", help_text="Upstream remote URL for sync"),
36
+ ),
37
+ migrations.AddField(
38
+ model_name="historicalfossilrepository",
39
+ name="upstream_artifacts_available",
40
+ field=models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream"),
41
+ ),
42
+ ]
--- a/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py
+++ b/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py
+++ b/fossil/migrations/0002_fossilrepository_last_sync_at_and_more.py
@@ -0,0 +1,42 @@
1 # Generated by Django 5.2.12 on 2026-04-07 00:49
2
3 from django.db import migrations, models
4
5
6 class Migration(migrations.Migration):
7 dependencies = [
8 ("fossil", "0001_initial"),
9 ]
10
11 operations = [
12 migrations.AddField(
13 model_name="fossilrepository",
14 name="last_sync_at",
15 field=models.DateTimeField(blank=True, null=True),
16 ),
17 migrations.AddField(
18 model_name="fossilrepository",
19 name="remote_url",
20 field=models.URLField(blank=True, default="", help_text="Upstream remote URL for sync"),
21 ),
22 migrations.AddField(
23 model_name="fossilrepository",
24 name="upstream_artifacts_available",
25 field=models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream"),
26 ),
27 migrations.AddField(
28 model_name="historicalfossilrepository",
29 name="last_sync_at",
30 field=models.DateTimeField(blank=True, null=True),
31 ),
32 migrations.AddField(
33 model_name="historicalfossilrepository",
34 name="remote_url",
35 field=models.URLField(blank=True, default="", help_text="Upstream remote URL for sync"),
36 ),
37 migrations.AddField(
38 model_name="historicalfossilrepository",
39 name="upstream_artifacts_available",
40 field=models.PositiveIntegerField(default=0, help_text="New artifacts available from upstream"),
41 ),
42 ]

No diff available

--- a/fossil/models.py
+++ b/fossil/models.py
@@ -0,0 +1,3 @@
1
+syncpstream remote URL for syncme
2
+
3
+ @pro
--- a/fossil/models.py
+++ b/fossil/models.py
@@ -0,0 +1,3 @@
 
 
 
--- a/fossil/models.py
+++ b/fossil/models.py
@@ -0,0 +1,3 @@
1 syncpstream remote URL for syncme
2
3 @pro
--- a/fossil/reader.py
+++ b/fossil/reader.py
@@ -0,0 +1,976 @@
1
+"""Read-only interface to Fossil's SQLite database.
2
+
3
+Each .fossil file is a SQLite database containing all repo data:
4
+code, timeline, tickets, wiki, forum. This module reads them directly
5
+without requiring the fossil binary.
6
+"""
7
+
8
+import contextlib
9
+import sqlite3
10
+import zlib
11
+from dataclasses import dataclass, field
12
+from datetime import UTC, datetime
13
+from pathlib import Path
14
+
15
+
16
+@dataclass
17
+class TimelineEntry:
18
+ rid: int
19
+ uuid: str
20
+ event_type: str # ci=checkin, w=wiki, t=ticket, g=tag, e=technote, f=forum
21
+ timestamp: datetime
22
+ user: str
23
+ comment: str
24
+ branch: str = ""
25
+ parent_rid: int = 0 # primary parent rid for DAG drawing
26
+ is_merge: bool = False # has multiple parents
27
+ s for merge connectors
28
+ rail: int = 0 # column position for DAG graph
29
+
30
+
31
+@dataclass
32
+class FileEntry:
33
+ name: str
34
+ uuid: str
35
+ size: int
36
+ is_dir: bool = False
37
+ last_commit_message: str = ""
38
+ last_commit_user: str = ""
39
+ last_commit_time: datetime | None = None
40
+
41
+
42
+@dataclass
43
+class CheckinDetail:
44
+ uuid: str
45
+ timestamp: datetime
46
+ user: str
47
+ comment: str
48
+ branch: str = ""
49
+ parent_uuid: str = ""
50
+ is_merge: bool = False
51
+ files_changed: list = None # list of dicts: {name, change_type, uuid, prev_uuid}
52
+
53
+ def __post_init__(self):
54
+ if self.files_changed is None:
55
+ self.files_changed = []
56
+
57
+
58
+@dataclass
59
+class TicketEntry:
60
+ uuid: str
61
+ title: str
62
+ status: str
63
+ type: str
64
+ created: datetime
65
+ owner: str
66
+ subsystem: str = ""
67
+ priority: str = ""
68
+ severity: str = ""
69
+ resolution: str = ""
70
+ body: str = "" # main comment/description
71
+
72
+
73
+@dataclass
74
+class WikiPage:
75
+ name: str
76
+ content: str
77
+ last_modified: datetime
78
+ user: str
79
+
80
+
81
+@dataclass
82
+class ForumPost:
83
+ uuid: str
84
+ title: str
85
+ body: str
86
+ timestamp: datetime
87
+ user: str
88
+ in_reply_to: str = ""
89
+
90
+
91
+@dataclass
92
+class RepoMetadata:
93
+ project_name: str = ""
94
+ project_code: str = ""
95
+ checkin_count: int = 0
96
+ file_count: int = 0
97
+ wiki_page_count: int = 0
98
+ ticket_count: int = 0
99
+ branches: list[str] = field(default_factory=list)
100
+
101
+
102
+def _julian_to_datetime(julian: float) -> datetime:
103
+ """Convert Julian day number to Python datetime (UTC)."""
104
+
105
+ # Julian day epoch is Jan 1, 4713 BC (proleptic Julian calendar)
106
+ # Unix epoch in Julian days = 2440587.5
107
+ unix_ts = (julian - 2440587.5) * 86400.0
108
+ return datetime.fromtimestamp(unix_ts, tz=UTC)
109
+
110
+
111
+def _apply_fossil_delta(source: bytes, delta: bytes) -> bytes:
112
+ """Apply a Fossil delta to a source blob to produce the output.
113
+
114
+ Fossil delta format: output_size\\n then commands:
115
+ - @offset,length: copy 'length' bytes from source starting at 'offset'
116
+ - :length:data: insert 'length' bytes of literal data
117
+ - length@ or length,offset: shorthand copy commands
118
+
119
+ The actual format uses a base-64-like encoding for integers.
120
+ See: https://fossil-scm.org/home/doc/trunk/www/delta_format.wiki
121
+ """
122
+ if not delta:
123
+ return source
124
+
125
+ pos = 0
126
+ out = bytearray()
127
+
128
+ def read_int():
129
+ nonlocal pos
130
+ val = 0
131
+ while pos < len(delta):
132
+ c = delta[pos : pos + 1]
133
+ if c in b"0123456789":
134
+ val = val * 64 + (c[0] - 48)
135
+ elif c in b"ABCDEFGHIJKLMNOPQRSTUVWXYZ":
136
+ val = val * 64 + (c[0] - 55)
137
+ elif c in b"abcdefghijklmnopqrstuvwxyz":
138
+ val = val * 64 + (c[0] - 87)
139
+ elif c == b".":
140
+ val = val * 64 + 62
141
+ elif c == b"/":
142
+ val = val * 64 + 63
143
+ else:
144
+ break
145
+ pos += 1
146
+ return val
147
+
148
+ # Read output size
149
+ output_size = read_int()
150
+ if pos < len(delta) and delta[pos : pos + 1] == b"\n":
151
+ pos += 1
152
+
153
+ while pos < len(delta):
154
+ count = read_int()
155
+ if pos >= len(delta):
156
+ break
157
+ cmd = delta[pos : pos + 1]
158
+ pos += 1
159
+
160
+ if cmd == b"@":
161
+ # Copy from source: count bytes starting at offset
162
+ offset = read_int()
163
+ if pos < len(delta) and delta[pos : pos + 1] == b",":
164
+ pos += 1
165
+ out.extend(source[offset : offset + count])
166
+ elif cmd == b",":
167
+ # Copy from source at offset=count, length follows
168
+ offset = count
169
+ length = read_int()
170
+ if pos < len(delta) and delta[pos : pos + 1] in (b"\n", b";"):
171
+ pos += 1
172
+ out.extend(source[offset : offset + length])
173
+ elif cmd == b":":
174
+ # Insert literal data
175
+ out.extend(delta[pos : pos + count])
176
+ pos += count
177
+ elif cmd == b";":
178
+ # End of delta with checksum
179
+ break
180
+ elif cmd == b"\n":
181
+ continue
182
+
183
+ return bytes(out[:output_size]) if output_size else bytes(out)
184
+
185
+
186
+def _decompress_blob(data: bytes) -> bytes:
187
+ """Decompress a Fossil blob.
188
+
189
+ Fossil stores blobs with a 4-byte big-endian size prefix followed by
190
+ zlib-compressed content. The size prefix is the uncompressed size.
191
+ """
192
+ if not data:
193
+ return b""
194
+ # Fossil prepends uncompressed size as 4-byte big-endian int
195
+ if len(data) > 4:
196
+ payload = data[4:]
197
+ try:
198
+ return zlib.decompress(payload)
199
+ except zlib.error:
200
+ pass
201
+ # Fallback: try without size prefix
202
+ try:
203
+ return zlib.decompress(data)
204
+ except zlib.error:
205
+ pass
206
+ try:
207
+ return zlib.decompress(data, -zlib.MAX_WBITS)
208
+ except zlib.error:
209
+ return data # Already uncompressed or unknown format
210
+
211
+
212
+def _extract_wiki_content(artifact_text: str) -> str:
213
+ """Extract wiki body from a Fossil wiki artifact.
214
+
215
+ Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash>
216
+ The W card specifies the byte count of the content that follows.
217
+ """
218
+ import re
219
+
220
+ match = re.search(r"^W (\d+)\n", artifact_text, re.MULTILINE)
221
+ if not match:
222
+ return ""
223
+ start = match.end()
224
+ size = int(match.group(1))
225
+ return artifact_text[start : start + size]
226
+
227
+
228
+class FossilReader:
229
+ """Read-only interface to a .fossil SQLite database."""
230
+
231
+ def __init__(self, path: Path):
232
+ self.path = path
233
+ self._conn: sqlite3.Connection | None = None
234
+
235
+ def __enter__(self):
236
+ self._conn = self._connect()
237
+ return self
238
+
239
+ def __exit__(self, *args):
240
+ if self._conn:
241
+ self._conn.close()
242
+ self._conn = None
243
+
244
+ def _connect(self) -> sqlite3.Connection:
245
+ uri = f"file:{self.path}?mode=ro"
246
+ conn = sqlite3.connect(uri, uri=True)
247
+ conn.row_factory = sqlite3.Row
248
+ return conn
249
+
250
+ @property
251
+ def conn(self) -> sqlite3.Connection:
252
+ if self._conn is None:
253
+ self._conn = self._connect()
254
+ return self._conn
255
+
256
+ def close(self):
257
+ if self._conn:
258
+ self._conn.close()
259
+ self._conn = None
260
+
261
+ # --- Metadata ---
262
+
263
+ def get_metadata(self) -> RepoMetadata:
264
+ meta = RepoMetadata()
265
+ meta.project_name = self.get_project_name()
266
+ meta.project_code = self.get_project_code()
267
+ meta.checkin_count = self.get_checkin_count()
268
+ with contextlib.suppress(sqlite3.OperationalError):
269
+ meta.ticket_count = self.conn.execute("SELECT count(*) FROM ticket").fetchone()[0]
270
+ with contextlib.suppress(sqlite3.OperationalError):
271
+ meta.wiki_page_count = self.conn.execute(
272
+ "SELECT count(DISTINCT substr(tagname,6)) FROM tag WHERE tagname LIKE 'wiki-%'"
273
+ ).fetchone()[0]
274
+ return meta
275
+
276
+ def get_project_name(self) -> str:
277
+ try:
278
+ row = self.conn.execute("SELECT value FROM config WHERE name='project-name'").fetchone()
279
+ return row[0] if row else ""
280
+ except sqlite3.OperationalError:
281
+ return ""
282
+
283
+ def get_project_code(self) -> str:
284
+ try:
285
+ row = self.conn.execute("SELECT value FROM config WHERE name='project-code'").fetchone()
286
+ return row[0] if row else ""
287
+ except sqlite3.OperationalError:
288
+ return ""
289
+
290
+ def get_checkin_count(self) -> int:
291
+ try:
292
+ row = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone()
293
+ return row[0] if row else 0
294
+ except sqlite3.OperationalError:
295
+ return 0
296
+
297
+ # --- User Activity ---
298
+
299
+ def get_user_activity(self, username: str, limit: int = 50) -> dict:
300
+ """Get activity summary for a specific user."""
301
+ result = {"checkins": [], "checkin_count": 0, "ticket_count": 0, "wiki_count": 0, "forum_count": 0}
302
+ try:
303
+ # Checkin count
304
+ row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='ci'", (username,)).fetchone()
305
+ result["checkin_count"] = row[0] if row else 0
306
+
307
+ # Recent checkins
308
+ rows = self.conn.execute(
309
+ "SELECT blob.uuid, event.mtime, event.comment FROM event "
310
+ "JOIN blob ON event.objid=blob.rid WHERE event.user=? AND event.type='ci' "
311
+ "ORDER BY event.mtime DESC LIMIT ?",
312
+ (username, limit),
313
+ ).fetchall()
314
+ for r in rows:
315
+ result["checkins"].append(
316
+ {
317
+ "uuid": r["uuid"],
318
+ "timestamp": _julian_to_datetime(r["mtime"]),
319
+ "comment": r["comment"] or "",
320
+ }
321
+ )
322
+
323
+ # Wiki edit count
324
+ row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='w'", (username,)).fetchone()
325
+ result["wiki_count"] = row[0] if row else 0
326
+
327
+ # Forum post count
328
+ row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='f'", (username,)).fetchone()
329
+ result["forum_count"] = row[0] if row else 0
330
+
331
+ # Ticket-related event count
332
+ row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='t'", (username,)).fetchone()
333
+ result["ticket_count"] = row[0] if row else 0
334
+ return files
335
+
336
+ def get_commit_activity(self, weeks: int = 52) -> list[dict]:
337
+ """Get weekly commit counts for the last N weeks. Returns [{week, count}]."""
338
+ activity = []
339
+ try:
340
+ # Julian day for "now" minus weeks*7 days
341
+ rows = self.conn.execute(
342
+ """
343
+ SELECT cast((julianday('now') - event.mtime) / 7 as integer) as weeks_ago,
344
+ count(*) as cnt
345
+ FROM event
346
+ WHERE event.type = 'ci'
347
+ AND event.mtime > julianday('now') - ?
348
+ GROUP BY weeks_ago
349
+ ORDER BY weeks_ago DESC
350
+ """,
351
+ (weeks * 7,),
352
+ ).fetchall()
353
+
354
+ # Build a full list with zeros for empty weeks
355
+ counts = {r["weeks_ago"]: r["cnt"] for r in rows}
356
+ for w in range(weeks - 1, -1, -1):
357
+ activity.append({"week": w, "count": counts.get(w, 0)})
358
+ except sqlite3.OperationalError:
359
+ pass
360
+ retop_contributors(self, limit: int = 10) -> list[dict]:
361
+ """Get top contributors by checkin count."""
362
+ contributors = []
363
+ try:
364
+ rows = self.conn.execute(
365
+ "SELECT user, count(*) as cnt FROM event WHERE type='ci' GROUP BY user ORDER BY cnt DESC LIMIT ?",
366
+ (limit,),
367
+ ).fetchall()
368
+ for r in rows:
369
+ contributors.append({"user": r["user"], "count": r["cnt"]})
370
+ except sqlite3.OperationalError:
371
+ pass
372
+ return contributors
373
+
374
+ def get_branches(self) -> list[dict]:
375
+ """Get all branches with their latest checkin info."""
376
+ branches = []
377
+ try:
378
+ rows = self.conn.execute(
379
+ """
380
+ SELECT tag.tagname, max(event.mtime) as last_mtime, event.user,
381
+ count(tagxref.rid) as checkin_count, blob.uuid
382
+ FROM tag
383
+ JOIN tagxref ON tag.tagid = tagxref.tagid
384
+ JOIN event ON tagxref.rid = event.objid
385
+ JOIN blob ON event.objid = blob.rid
386
+ WHERE tag.tagname LIKE 'sym-%' AND event.type = 'ci'
387
+ GROUP BY tag.tagname
388
+ ORDER BY last_mtime DESC
389
+ """,
390
+ ).fetchall()
391
+ for r in rows:
392
+ branches.append(
393
+ {
394
+ "name": r["tagname"].replace("sym-", "", 1),
395
+ "last_checkin": _julian_to_datetime(r["last_mtime"]),
396
+ "last_user": r["user"] or "",
397
+ "checkin_count": r["checkin_count"],
398
+ "last_uuid": r["uuid"],
399
+ }
400
+ )
401
+ except sqlite3.OperationalError:
402
+ pass
403
+ return branches
404
+
405
+ def get_tags(self) -> list[dict]:
406
+ """Get all tags (non-branch sym- tags that mark specific checkins)."""
407
+ tags = []
408
+ try:
409
+ rows = self.conn.execute(
410
+ """
411
+ SELECT tag.tagname, event.mtime, event.user, blob.uuid
412
+ FROM tag
413
+ JOIN tagxref ON tag.tagid = tagxref.tagid AND tagxref.value > 0
414
+ JOIN event ON tagxref.rid = event.objid
415
+ JOIN blob ON event.objid = blob.rid
416
+ WHERE tag.tagname LIKE 'sym-%'
417
+ AND tag.tagname NOT IN (SELECT tagname FROM tag JOIN tagxref ON tag.tagid=tagxref.tagid GROUP BY tagname HAVING count(*) > 5)
418
+ ORDER BY event.mtime DESC
419
+ LIMIT 100
420
+ """,
421
+ ).fetchall()
422
+ for r in rows:
423
+ tags.append(
424
+ {
425
+ "name": r["tagname"].replace("sym-", "", 1),
426
+ "timestamp": _julian_to_datetime(r["mtime"]),
427
+ "user": r["user"] or "",
428
+ "uuid": r["uuid"],
429
+ }
430
+ )
431
+ except sqlite3.OperationalError:
432
+ pass
433
+ return tags
434
+
435
+ def get_repo_statistics(self) -> dict:
436
+ """Get comprehensive repository statistics."""
437
+ stats = {}
438
+ try:
439
+ stats["total_artifacts"] = self.conn.execute("SELECT count(*) FROM blob").fetchone()[0]
440
+ stats["total_events"] = self.conn.execute("SELECT count(*) FROM event").fetchone()[0]
441
+ stats["checkin_count"] = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone()[0]
442
+ stats["wiki_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='w'").fetchone()[0]
443
+ stats["ticket_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='t'").fetchone()[0]
444
+ stats["forum_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='f'").fetchone()[0]
445
+
446
+ # First and last checkin dates
447
+ first = self.conn.execute("SELECT min(mtime) FROM event WHERE type='ci'").fetchone()
448
+ last = self.conn.execute("SELECT max(mtime) FROM event WHERE type='ci'").fetchone()
449
+ if first and first[0]:
450
+ stats["first_checkin"] = _julian_to_datetime(first[0])
451
+ if last and last[0]:
452
+ stats["last_checkin"] = _julian_to_datetime(last[0])
453
+
454
+ # Unique contributors
455
+ stats["contributors"] = self.conn.execute("SELECT count(DISTINCT user) FROM event WHERE type='ci'").fetchone()[0]
456
+
457
+ # DB size
458
+ stats["db_pages"] = self.conn.execute("PRAGMA page_count").fetchone()[0]
459
+ stats["page_size"] = self.conn.execute("PRAGMA page_size").fetchone()[0]
460
+ stats["db_size_mb"] = round((stats["db_pages"] * stats["page_size"]) / (1024 * 1024), 1)
461
+ except sqlite3.OperationalError:
462
+ pass
463
+ return stats
464
+
465
+ # --- Timeline ---
466
+
467
+ def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
468
+ sql = """
469
+ SELECT blob.rid, blob.uuid, event.type, event.mtime, event.user, event.comment
470
+ FROM event
471
+ JOIN blob ON event.objid = blob.rid
472
+ """
473
+ params: list = []
474
+ if event_type:
475
+ sql += " WHERE event.type = ?"
476
+ params.append(event_type)
477
+ sql += " ORDER BY event.mtime DESC LIMIT ? OFFSET ?"
478
+ params.extend([limit, offset])
479
+
480
+ entries = []
481
+ try:
482
+ for row in self.conn.execute(sql, params):
483
+ branch = ""
484
+ parent_rid = 0
485
+ is_merge = False
486
+
487
+ try:
488
+ br = self.conn.execute(
489
+ "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid "
490
+ "WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
491
+ (row["rid"],),
492
+ ).fetchone()
493
+ if br:
494
+ branch = br[0].replace("sym-", "", 1)
495
+ except sqlite3.OperationalError:
496
+ pass
497
+
498
+ # Get parent info from pli_rids = []
499
+ if row["type"] == "ci":
500
+ try:
501
+ parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
502
+ for p in parents:
503
+ if p["isprim"]:
504
+ parent_rid = p["pid-- Timeline ---
505
+
506
+ def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
507
+ sql = """
508
+ SELECT blob.rid, blob.uuid, event.type, event.mtime, event.user, event.comment
509
+ FROM event
510
+ JOIN blob ON event.objid = blob.rid
511
+ """
512
+ params: list = []
513
+ if event_type:
514
+ sql += " WHERE event.type = ?"
515
+ params.append(event_type)
516
+ sql += " ORDER BY event.mtime DESC LIMIT ? OFFSET ?"
517
+ params.extend([limit, offset])
518
+
519
+ entries = []
520
+ try:
521
+ ["tkt_id" br = self.conn.execute(
522
+ "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid "
523
+ "WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
524
+ (row["rid"],),
525
+ ).fetchone()
526
+ if br:
527
+ branch = br[0].replace("sym-", "", 1)
528
+ except sqlite3.OperationalError:
529
+ pass
530
+
531
+ # Get parent info from plink for DAG
532
+ merge_parent_rids = []
533
+ if row["type"] == "ci":
534
+ try:
535
+ parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
536
+ for p in parents:
537
+ if p["isprim"]:
538
+ parent_rid = p["pid"]
539
+ else:
540
+ merge_parent_rids.append(p["pid"])
541
+ is_merge = len(parents) > 1
542
+ except sqlite3.OperationalError:
543
+ pass
544
+
545
+ entries.append(
546
+ TimelineEntry(
547
+ rid=row["rid"],
548
+ uuid=row["uuid"],
549
+ event_type=row["type"],
550
+ timestamp=_julian_to_datetime(row["mtime"]),
551
+ user=row["user"] or "",
552
+ comment=row["comment"] or "",
553
+ branch=branch,
554
+ parent_rid=parent_rid,
555
+ is_merge=is_merge,
556
+ merge_parent_rids=merge_parent_rids,
557
+ )
558
+ )
559
+ except sqlite3.OperationalError:
560
+ pass
561
+
562
+ # Assign rail positions based on branches
563
+ branch_rails: dict[str, int] = {}
564
+ next_rail = 0
565
+ for entry in entries:
566
+ if entry.event_type != "ci":
567
+ entry.rail = -1 # non-checkin events don't get a rail
568
+ continue
569
+ b = entry.branch or "trunk"
570
+ if b not in branch_rails:
571
+ branch_rails[b] = next_rail
572
+ next_rail += 1
573
+ entry.rail = branch_rails[b]
574
+
575
+ return entries
576
+
577
+ # --- Checkin Detail ---
578
+
579
+ def get_checkin_detail(self, uuid: str) -> CheckinDetail | None:
580
+ """Get full details for a specific checkin, including changed files."""
581
+ try:
582
+ row = self.conn.execute(
583
+ "SELECT blob.rid, blob.uuid, event.mtime, event.user, event.comment "
584
+ "FROM event JOIN blob ON event.objid=blob.rid "
585
+ "WHERE blob.uuid LIKE ? AND event.type='ci'",
586
+ (uuid + "%",),
587
+ ).fetchone()
588
+ if not row:
589
+ return None
590
+
591
+ rid = row["rid"]
592
+ full_uuid = row["uuid"]
593
+
594
+ # Get branch
595
+ branch = ""
596
+ try:
597
+ br = self.conn.execute(
598
+ "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
599
+ (rid,),
600
+ ).fetchone()
601
+ if br:
602
+ branch = br[0].replace("sym-", "", 1)
603
+ except sqlite3.OperationalError:
604
+ pass
605
+
606
+ # Get parent
607
+ parent_uuid = ""
608
+ is_merge = False
609
+ try:
610
+ parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (rid,)).fetchall()
611
+ for p in parents:
612
+ if p["isprim"]:
613
+ parent_row = self.conn.execute("SELECT uuid FROM blob WHERE rid=?", (p["pid"],)).fetchone()
614
+ if parent_row:
615
+ parent_uuid = parent_row["uuid"]
616
+ is_merge = len(parents) > 1
617
+ except sqlite3.OperationalError:
618
+ pass
619
+
620
+ # Get changed files from mlink
621
+ files_changed = []
622
+ try:
623
+ mlinks = self.conn.execute(
624
+ """
625
+ SELECT fn.name, ml.fid, ml.pid,
626
+ b_new.uuid as new_uuid,
627
+ b_old.uuid as old_uuid
628
+ FROM mlink ml
629
+ JOIN filename fn ON ml.fnid = fn.fnid
630
+ LEFT JOIN blob b_new ON ml.fid = b_new.rid
631
+ LEFT JOIN blob b_old ON ml.pid = b_old.rid
632
+ WHERE ml.mid = ?
633
+ ORDER BY fn.name
634
+ """,
635
+ (rid,),
636
+ ).fetchall()
637
+ for ml in mlinks:
638
+ if ml["fid"] == 0:
639
+ change_type = "deleted"
640
+ elif ml["pid"] == 0:
641
+ change_type = "added"
642
+ else:
643
+ change_type = "modified"
644
+ files_changed.append(
645
+ {
646
+ "name": ml["name"],
647
+ "change_type": change_type,
648
+ "uuid": ml["new_uuid"] or "",
649
+ "prev_uuid": ml["old_uuid"] or "",
650
+ }
651
+ )
652
+ except sqlite3.OperationalError:
653
+ pass
654
+
655
+ return CheckinDetail(
656
+ uuid=full_uuid,
657
+ timestamp=_julian_to_datetime(row["mtime"]),
658
+ user=row["user"] or "",
659
+ comment=row["comment"] or "",
660
+ branch=branch,
661
+ parent_uuid=parent_uuid,
662
+ is_merge=is_merge,
663
+ files_changed=files_changed,
664
+ )
665
+ except sqlite3.OperationalError:
666
+ return None
667
+
668
+ # --- Code / Files ---
669
+
670
+ def get_latest_checkin_uuid(self) -> str | None:
671
+ try:
672
+ row = self.conn.execute(
673
+ "SELECT blob.uuid FROM event JOIN blob ON event.objid=blob.rid WHERE event.type='ci' ORDER BY event.mtime DESC LIMIT 1"
674
+ ).fetchone()
675
+ return row[0] if row else None
676
+ except sqlite3.OperationalError:
677
+ return None
678
+
679
+ def get_files_at_checkin(self, checkin_uuid: str | None = None) -> list[FileEntry]:
680
+ """Get the cumulative file list at a given checkin, with last commit info per file."""
681
+ if checkin_uuid is None:
682
+ checkin_uuid = self.get_latest_checkin_uuid()
683
+ if not checkin_uuid:
684
+ return []
685
+
686
+ try:
687
+ # Build cumulative file state: for each filename, find the latest mlink entry
688
+ # where fid > 0 (fid=0 means file was deleted)
689
+ rows = self.conn.execute(
690
+ """
691
+ SELECT fn.name, b.uuid, b.size,
692
+ e.comment, e.user, e.mtime
693
+ FROM (
694
+ SELECT ml.fnid, ml.fid,
695
+ MAX(e2.mtime) as max_mtime
696
+ FROM mlink ml
697
+ JOIN event e2 ON ml.mid = e2.objid
698
+ WHERE e2.type = 'ci'
699
+ GROUP BY ml.fnid
700
+ ) latest
701
+ JOIN mlink ml2 ON ml2.fnid = latest.fnid
702
+ JOIN event e ON ml2.mid = e.objid AND e.mtime = latest.max_mtime AND e.type = 'ci'
703
+ JOIN filename fn ON latest.fnid = fn.fnid
704
+ LEFT JOIN blob b ON ml2.fid = b.rid
705
+ WHERE ml2.fid > 0
706
+ ORDER BY fn.name
707
+ """,
708
+ ).fetchall()
709
+
710
+ return [
711
+ FileEntry(
712
+ name=r["name"],
713
+ uuid=r["uuid"] or "",
714
+ size=r["size"] or 0,
715
+ last_commit_message=r["comment"] or "",
716
+ last_commit_user=r["user"] or "",
717
+ last_commit_time=_julian_to_datetime(r["mtime"]) if r["mtime"] else None,
718
+ )
719
+ for r in rows
720
+ ]
721
+ except sqlite3.OperationalError:
722
+ return []
723
+
724
+ def get_file_content(self, blob_uuid: str) -> bytes:
725
+ """Get file content, resolving delta compression chains."""
726
+ try:
727
+ return self._resolve_blob(blob_uuid)
728
+ except Exception:
729
+ return b""
730
+
731
+ def _resolve_blob(self, uuid_or_rid, by_rid=False) -> bytes:
732
+ """Resolve a blob, following delta chains if needed."""
733
+ if by_rid:
734
+ row = self.conn.execute("SELECT rid, content FROM blob WHERE rid=?", (uuid_or_rid,)).fetchone()
735
+ else:
736
+ row = self.conn.execute("SELECT rid, content FROM blob WHERE uuid=?", (uuid_or_rid,)).fetchone()
737
+ if not row or not row["content"]:
738
+ return b""
739
+
740
+ rid = row["rid"]
741
+ data = _decompress_blob(row["content"])
742
+
743
+ # Check if this blob is delta-compressed
744
+ delta_row = self.conn.execute("SELECT srcid FROM delta WHERE rid=?", (rid,)).fetchone()
745
+ if delta_row:
746
+ # Recursively resolve the source blob
747
+ source = self._resolve_blob(delta_row["srcid"], by_rid=True)
748
+ return _apply_fossil_delta(source, data)
749
+
750
+ return data
751
+
752
+ def get_file_history(self, filename: str, limit: int = 50) -> list[dict]:
753
+ """Get commit history for a specific file."""
754
+ history = []
755
+ try:
756
+ rows = self.conn.execute(
757
+ """
758
+ SELECT blob.uuid, event.mtime, event.user, event.comment
759
+ FROM mlink ml
760
+ JOIN filename fn ON ml.fnid = fn.fnid
761
+ JOIN event ON ml.mid = event.objid
762
+ JOIN blob ON event.objid = blob.rid
763
+ WHERE fn.name = ? AND event.type = 'ci'
764
+ ORDER BY event.mtime DESC
765
+ LIMIT ?
766
+ """,
767
+ (filename, limit),
768
+ ).fetchall()
769
+ for r in rows:
770
+ history.append(
771
+ {
772
+ "uuid": r["uuid"],
773
+ "timestamp": _julian_to_datetime(r["mtime"]),
774
+ "user": r["user"] or "",
775
+ "comment": r["comment"] or "",
776
+ }
777
+ )
778
+ except sqlite3.OperationalError:
779
+ pass
780
+ return history
781
+
782
+ def search(self, query: str, limit: int = 50) -> dict:
783
+ """Search across checkins, tickets, and wiki pages."""
784
+ results = {"checkins": [], "tickets": [], "wiki": []}
785
+ q = f"%{query}%"
786
+ try:
787
+ # Search checkin comments
788
+ rows = self.conn.execute(
789
+ "SELECT blob.uuid, event.mtime, event.user, event.comment FROM event "
790
+ "JOIN blob ON event.objid=blob.rid WHERE event.type='ci' AND event.comment LIKE ? "
791
+ "ORDER BY event.mtime DESC LIMIT ?",
792
+ (q, limit),
793
+ ).fetchall()
794
+ for r in rows:
795
+ results["checkins"].append(
796
+ {
797
+ "uuid": r["uuid"],
798
+ "timestamp": _julian_to_datetime(r["mtime"]),
799
+ "user": r["user"] or "",
800
+ "comment": r["comment"] or "",
801
+ }
802
+ )
803
+ except sqlite3.OperationalError:
804
+ pass
805
+ try:
806
+ # Search ticket titles
807
+ rows = self.conn.execute(
808
+ "SELECT tkt_uuid, title, status, tkt_ctime FROM ticket WHERE title LIKE ? ORDER BY tkt_ctime DESC LIMIT ?",
809
+ (q, limit),
810
+ ).fetchall()
811
+ for r in rows:
812
+ results["tickets"].append(
813
+ {
814
+ "uuid": r["tkt_uuid"],
815
+ "title": r["title"] or "",
816
+ "status": r["status"] or "",
817
+ "created": _julian_to_datetime(r["tkt_ctime"]) if r["tkt_ctime"] else None,
818
+ }
819
+ )
820
+ except sqlite3.OperationalError:
821
+ pass
822
+ try:
823
+ # Search wiki page names
824
+ rows = self.conn.execute(
825
+ "SELECT DISTINCT substr(tagname, 6) as name FROM tag WHERE tagname LIKE ? ORDER BY name LIMIT ?",
826
+ (f"wiki-%{query}%", limit),
827
+ ).fetchall()
828
+ for r in rows:
829
+ results["wiki"].append({"name": r["name"]})
830
+ except sqlite3.OperationalError:
831
+ pass
832
+ return results
833
+
834
+ # --- Tickets ---
835
+
836
+ def get_tickets(self, status: str | None = None, limit: int = 50) -> list[TicketEntry]:
837
+ sql = "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority FROM ticket"
838
+ params: list = []
839
+ if status:
840
+ sql += " WHERE status = ?"
841
+ params.append(status)
842
+ sql += " ORDER BY tkt_ctime DESC LIMIT ?"
843
+ params.append(limit)
844
+
845
+ entries = []
846
+ try:
847
+ for row in self.conn.execute(sql, params):
848
+ entries.append(
849
+ TicketEntry(
850
+ uuid=row["tkt_uuid"] or "",
851
+ title=row["title"] or "",
852
+ status=row["status"] or "",
853
+ type=row["type"] or "",
854
+ created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
855
+ owner="",
856
+ subsystem=row["subsystem"] or "",
857
+ priority=row["priority"] or "",
858
+ )
859
+ )
860
+ except sqlite3.OperationalError:
861
+ pass
862
+ return entries
863
+
864
+ def get_ticket_detail(self, uuid: str) -> TicketEntry | None:
865
+ try:
866
+ row = self.conn.execute(
867
+ "SELECT tkt_id, tkt_uuid, title, status, type, tkt_ctime, subsystem, priority, severity, resolution, comment "
868
+ "FROM ticket WHERE tkt_uuid LIKE ?",
869
+ (uuid + "%",),
870
+ ).fetchone()
871
+ if not row:
872
+ return None
873
+
874
+ body = row["comment"] or ""
875
+
876
+ # If comment is empty, try ticketchng.icomment (newer Fossil stores descriptions there)
877
+ if not body:
878
+ try:
879
+ chng = self.conn.execute(
880
+ "SELECT icomment, login FROM ticketchng WHERE tkt_id=? ORDER BY tkt_mtime ASC LIMIT 1",
881
+ (row["tkt_id"],),
882
+ ).fetchone()
883
+ if chng and chng["icomment"]:
884
+ body = chng["icomment"]
885
+ except sqlite3.OperationalError:
886
+ pass
887
+
888
+ return TicketEntry(
889
+ uuid=row["tkt_uuid"],
890
+ title=row["title"] or "",
891
+ status=row["status"] or "",
892
+ type=row["type"] or "",
893
+ created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
894
+ owner="",
895
+ subsystem=row["subsystem"] or "",
896
+ priority=row["priority"] or "",
897
+ severity=row["severity"] or "",
898
+ resolution=row["resolution"] or "",
899
+ body=body,
900
+ )
901
+ except sqlite3.OperationalError:
902
+ return None
903
+
904
+ def get_ticket_comments(self, uuid: str) -> list[dict]:
905
+ """Get all comments/changes for a ticket."""
906
+ comments = []
907
+ try:
908
+ row = self.conn.execute("SELECT tkt_id FROM ticket WHERE tkt_uuid LIKE ?", (uuid + "%",)).fetchone()
909
+ if not row:
910
+ return []
911
+ rows = self.conn.execute(
912
+ "SELECT tkt_mtime, login, username, icomment, mimetype FROM ticketchng WHERE tkt_id=? ORDER BY tkt_mtime ASC",
913
+ (row["tkt_id"],),
914
+ ).fetchall()
915
+ for r in rows:
916
+ if r["icomment"]:
917
+ comments.append(
918
+ {
919
+ "timestamp": _julian_to_datetime(r["tkt_mtime"]) if r["tkt_mtime"] else None,
920
+ "user": r["username"] or r["login"] or "",
921
+ "comment": r["icomment"],
922
+ "mimetype": r["mimetype"] or "text/plain",
923
+ }
924
+ )
925
+ except sqlite3.OperationalError:
926
+ pass
927
+ return comments
928
+
929
+ # --- Wiki ---
930
+
931
+ def get_wiki_pages(self) -> list[WikiPage]:
932
+ pages = []
933
+ try:
934
+ rows = self.conn.execute(
935
+ """
936
+ SELECT substr(tag.tagname, 6) as name, event.mtime, event.userly interface to F"""Read-only interface to Fossil's SQLite database.
937
+
938
+Each .fossil file is a SQLite database containing all repo data:
939
+code, timeline, tickets, wiki, forum. T WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w'
940
+ GROUP BY tag.tagname
941
+ HAVING event.mtime = MAX(event.mtime)
942
+ ORDER BY event.mtime DESC
943
+ """
944
+ ).fetchall()
945
+ for ropagser=? AND type='f'", (username, (row["tkt_id"],),
946
+ ).fetchall()
947
+ for r in rows:
948
+ if r["icomment"]:
949
+ comments.append(
950
+ {
951
+ "timestamp": _julian_to_datetime(r["tkt_mtime"]) if r["tkt_mtime"] else None,
952
+ "user": r["username"] or r["login"] or "",
953
+ "comment": r["icomment"],
954
+ "mimetype": r["mimetype"] or "text/plain",
955
+ }
956
+ )
957
+ except sqlite3.OperationalError:
958
+ pass
959
+ return comments
960
+
961
+ # --- Wiki ---
962
+
963
+ def get_wiki_pages(self) -> list[WikiPage]:
964
+ pages = []
965
+ try:
966
+ rows = self.conn.execute(
967
+ """
968
+ SELECT substr(tag.tagname, 6) as name, event.mtime, event.user,
969
+ blob.size as content_size
970
+ FROM tag
971
+ JOIN tagxref ON tag.tagid = tagxref.tagid
972
+ JOIN event ON tagxref.rid = event.objid
973
+ JOIN blob ON event.objid = blob.rid
974
+ WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w'
975
+ GROUP BY tag.tagname
976
+ HAVING e
--- a/fossil/reader.py
+++ b/fossil/reader.py
@@ -0,0 +1,976 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/reader.py
+++ b/fossil/reader.py
@@ -0,0 +1,976 @@
1 """Read-only interface to Fossil's SQLite database.
2
3 Each .fossil file is a SQLite database containing all repo data:
4 code, timeline, tickets, wiki, forum. This module reads them directly
5 without requiring the fossil binary.
6 """
7
8 import contextlib
9 import sqlite3
10 import zlib
11 from dataclasses import dataclass, field
12 from datetime import UTC, datetime
13 from pathlib import Path
14
15
16 @dataclass
17 class TimelineEntry:
18 rid: int
19 uuid: str
20 event_type: str # ci=checkin, w=wiki, t=ticket, g=tag, e=technote, f=forum
21 timestamp: datetime
22 user: str
23 comment: str
24 branch: str = ""
25 parent_rid: int = 0 # primary parent rid for DAG drawing
26 is_merge: bool = False # has multiple parents
27 s for merge connectors
28 rail: int = 0 # column position for DAG graph
29
30
31 @dataclass
32 class FileEntry:
33 name: str
34 uuid: str
35 size: int
36 is_dir: bool = False
37 last_commit_message: str = ""
38 last_commit_user: str = ""
39 last_commit_time: datetime | None = None
40
41
42 @dataclass
43 class CheckinDetail:
44 uuid: str
45 timestamp: datetime
46 user: str
47 comment: str
48 branch: str = ""
49 parent_uuid: str = ""
50 is_merge: bool = False
51 files_changed: list = None # list of dicts: {name, change_type, uuid, prev_uuid}
52
53 def __post_init__(self):
54 if self.files_changed is None:
55 self.files_changed = []
56
57
58 @dataclass
59 class TicketEntry:
60 uuid: str
61 title: str
62 status: str
63 type: str
64 created: datetime
65 owner: str
66 subsystem: str = ""
67 priority: str = ""
68 severity: str = ""
69 resolution: str = ""
70 body: str = "" # main comment/description
71
72
73 @dataclass
74 class WikiPage:
75 name: str
76 content: str
77 last_modified: datetime
78 user: str
79
80
81 @dataclass
82 class ForumPost:
83 uuid: str
84 title: str
85 body: str
86 timestamp: datetime
87 user: str
88 in_reply_to: str = ""
89
90
91 @dataclass
92 class RepoMetadata:
93 project_name: str = ""
94 project_code: str = ""
95 checkin_count: int = 0
96 file_count: int = 0
97 wiki_page_count: int = 0
98 ticket_count: int = 0
99 branches: list[str] = field(default_factory=list)
100
101
102 def _julian_to_datetime(julian: float) -> datetime:
103 """Convert Julian day number to Python datetime (UTC)."""
104
105 # Julian day epoch is Jan 1, 4713 BC (proleptic Julian calendar)
106 # Unix epoch in Julian days = 2440587.5
107 unix_ts = (julian - 2440587.5) * 86400.0
108 return datetime.fromtimestamp(unix_ts, tz=UTC)
109
110
111 def _apply_fossil_delta(source: bytes, delta: bytes) -> bytes:
112 """Apply a Fossil delta to a source blob to produce the output.
113
114 Fossil delta format: output_size\\n then commands:
115 - @offset,length: copy 'length' bytes from source starting at 'offset'
116 - :length:data: insert 'length' bytes of literal data
117 - length@ or length,offset: shorthand copy commands
118
119 The actual format uses a base-64-like encoding for integers.
120 See: https://fossil-scm.org/home/doc/trunk/www/delta_format.wiki
121 """
122 if not delta:
123 return source
124
125 pos = 0
126 out = bytearray()
127
128 def read_int():
129 nonlocal pos
130 val = 0
131 while pos < len(delta):
132 c = delta[pos : pos + 1]
133 if c in b"0123456789":
134 val = val * 64 + (c[0] - 48)
135 elif c in b"ABCDEFGHIJKLMNOPQRSTUVWXYZ":
136 val = val * 64 + (c[0] - 55)
137 elif c in b"abcdefghijklmnopqrstuvwxyz":
138 val = val * 64 + (c[0] - 87)
139 elif c == b".":
140 val = val * 64 + 62
141 elif c == b"/":
142 val = val * 64 + 63
143 else:
144 break
145 pos += 1
146 return val
147
148 # Read output size
149 output_size = read_int()
150 if pos < len(delta) and delta[pos : pos + 1] == b"\n":
151 pos += 1
152
153 while pos < len(delta):
154 count = read_int()
155 if pos >= len(delta):
156 break
157 cmd = delta[pos : pos + 1]
158 pos += 1
159
160 if cmd == b"@":
161 # Copy from source: count bytes starting at offset
162 offset = read_int()
163 if pos < len(delta) and delta[pos : pos + 1] == b",":
164 pos += 1
165 out.extend(source[offset : offset + count])
166 elif cmd == b",":
167 # Copy from source at offset=count, length follows
168 offset = count
169 length = read_int()
170 if pos < len(delta) and delta[pos : pos + 1] in (b"\n", b";"):
171 pos += 1
172 out.extend(source[offset : offset + length])
173 elif cmd == b":":
174 # Insert literal data
175 out.extend(delta[pos : pos + count])
176 pos += count
177 elif cmd == b";":
178 # End of delta with checksum
179 break
180 elif cmd == b"\n":
181 continue
182
183 return bytes(out[:output_size]) if output_size else bytes(out)
184
185
186 def _decompress_blob(data: bytes) -> bytes:
187 """Decompress a Fossil blob.
188
189 Fossil stores blobs with a 4-byte big-endian size prefix followed by
190 zlib-compressed content. The size prefix is the uncompressed size.
191 """
192 if not data:
193 return b""
194 # Fossil prepends uncompressed size as 4-byte big-endian int
195 if len(data) > 4:
196 payload = data[4:]
197 try:
198 return zlib.decompress(payload)
199 except zlib.error:
200 pass
201 # Fallback: try without size prefix
202 try:
203 return zlib.decompress(data)
204 except zlib.error:
205 pass
206 try:
207 return zlib.decompress(data, -zlib.MAX_WBITS)
208 except zlib.error:
209 return data # Already uncompressed or unknown format
210
211
212 def _extract_wiki_content(artifact_text: str) -> str:
213 """Extract wiki body from a Fossil wiki artifact.
214
215 Format: header cards (D/L/P/U lines), then W <size>\\n<content>\\nZ <hash>
216 The W card specifies the byte count of the content that follows.
217 """
218 import re
219
220 match = re.search(r"^W (\d+)\n", artifact_text, re.MULTILINE)
221 if not match:
222 return ""
223 start = match.end()
224 size = int(match.group(1))
225 return artifact_text[start : start + size]
226
227
228 class FossilReader:
229 """Read-only interface to a .fossil SQLite database."""
230
231 def __init__(self, path: Path):
232 self.path = path
233 self._conn: sqlite3.Connection | None = None
234
235 def __enter__(self):
236 self._conn = self._connect()
237 return self
238
239 def __exit__(self, *args):
240 if self._conn:
241 self._conn.close()
242 self._conn = None
243
244 def _connect(self) -> sqlite3.Connection:
245 uri = f"file:{self.path}?mode=ro"
246 conn = sqlite3.connect(uri, uri=True)
247 conn.row_factory = sqlite3.Row
248 return conn
249
250 @property
251 def conn(self) -> sqlite3.Connection:
252 if self._conn is None:
253 self._conn = self._connect()
254 return self._conn
255
256 def close(self):
257 if self._conn:
258 self._conn.close()
259 self._conn = None
260
261 # --- Metadata ---
262
263 def get_metadata(self) -> RepoMetadata:
264 meta = RepoMetadata()
265 meta.project_name = self.get_project_name()
266 meta.project_code = self.get_project_code()
267 meta.checkin_count = self.get_checkin_count()
268 with contextlib.suppress(sqlite3.OperationalError):
269 meta.ticket_count = self.conn.execute("SELECT count(*) FROM ticket").fetchone()[0]
270 with contextlib.suppress(sqlite3.OperationalError):
271 meta.wiki_page_count = self.conn.execute(
272 "SELECT count(DISTINCT substr(tagname,6)) FROM tag WHERE tagname LIKE 'wiki-%'"
273 ).fetchone()[0]
274 return meta
275
276 def get_project_name(self) -> str:
277 try:
278 row = self.conn.execute("SELECT value FROM config WHERE name='project-name'").fetchone()
279 return row[0] if row else ""
280 except sqlite3.OperationalError:
281 return ""
282
283 def get_project_code(self) -> str:
284 try:
285 row = self.conn.execute("SELECT value FROM config WHERE name='project-code'").fetchone()
286 return row[0] if row else ""
287 except sqlite3.OperationalError:
288 return ""
289
290 def get_checkin_count(self) -> int:
291 try:
292 row = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone()
293 return row[0] if row else 0
294 except sqlite3.OperationalError:
295 return 0
296
297 # --- User Activity ---
298
299 def get_user_activity(self, username: str, limit: int = 50) -> dict:
300 """Get activity summary for a specific user."""
301 result = {"checkins": [], "checkin_count": 0, "ticket_count": 0, "wiki_count": 0, "forum_count": 0}
302 try:
303 # Checkin count
304 row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='ci'", (username,)).fetchone()
305 result["checkin_count"] = row[0] if row else 0
306
307 # Recent checkins
308 rows = self.conn.execute(
309 "SELECT blob.uuid, event.mtime, event.comment FROM event "
310 "JOIN blob ON event.objid=blob.rid WHERE event.user=? AND event.type='ci' "
311 "ORDER BY event.mtime DESC LIMIT ?",
312 (username, limit),
313 ).fetchall()
314 for r in rows:
315 result["checkins"].append(
316 {
317 "uuid": r["uuid"],
318 "timestamp": _julian_to_datetime(r["mtime"]),
319 "comment": r["comment"] or "",
320 }
321 )
322
323 # Wiki edit count
324 row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='w'", (username,)).fetchone()
325 result["wiki_count"] = row[0] if row else 0
326
327 # Forum post count
328 row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='f'", (username,)).fetchone()
329 result["forum_count"] = row[0] if row else 0
330
331 # Ticket-related event count
332 row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='t'", (username,)).fetchone()
333 result["ticket_count"] = row[0] if row else 0
334 return files
335
336 def get_commit_activity(self, weeks: int = 52) -> list[dict]:
337 """Get weekly commit counts for the last N weeks. Returns [{week, count}]."""
338 activity = []
339 try:
340 # Julian day for "now" minus weeks*7 days
341 rows = self.conn.execute(
342 """
343 SELECT cast((julianday('now') - event.mtime) / 7 as integer) as weeks_ago,
344 count(*) as cnt
345 FROM event
346 WHERE event.type = 'ci'
347 AND event.mtime > julianday('now') - ?
348 GROUP BY weeks_ago
349 ORDER BY weeks_ago DESC
350 """,
351 (weeks * 7,),
352 ).fetchall()
353
354 # Build a full list with zeros for empty weeks
355 counts = {r["weeks_ago"]: r["cnt"] for r in rows}
356 for w in range(weeks - 1, -1, -1):
357 activity.append({"week": w, "count": counts.get(w, 0)})
358 except sqlite3.OperationalError:
359 pass
360 retop_contributors(self, limit: int = 10) -> list[dict]:
361 """Get top contributors by checkin count."""
362 contributors = []
363 try:
364 rows = self.conn.execute(
365 "SELECT user, count(*) as cnt FROM event WHERE type='ci' GROUP BY user ORDER BY cnt DESC LIMIT ?",
366 (limit,),
367 ).fetchall()
368 for r in rows:
369 contributors.append({"user": r["user"], "count": r["cnt"]})
370 except sqlite3.OperationalError:
371 pass
372 return contributors
373
374 def get_branches(self) -> list[dict]:
375 """Get all branches with their latest checkin info."""
376 branches = []
377 try:
378 rows = self.conn.execute(
379 """
380 SELECT tag.tagname, max(event.mtime) as last_mtime, event.user,
381 count(tagxref.rid) as checkin_count, blob.uuid
382 FROM tag
383 JOIN tagxref ON tag.tagid = tagxref.tagid
384 JOIN event ON tagxref.rid = event.objid
385 JOIN blob ON event.objid = blob.rid
386 WHERE tag.tagname LIKE 'sym-%' AND event.type = 'ci'
387 GROUP BY tag.tagname
388 ORDER BY last_mtime DESC
389 """,
390 ).fetchall()
391 for r in rows:
392 branches.append(
393 {
394 "name": r["tagname"].replace("sym-", "", 1),
395 "last_checkin": _julian_to_datetime(r["last_mtime"]),
396 "last_user": r["user"] or "",
397 "checkin_count": r["checkin_count"],
398 "last_uuid": r["uuid"],
399 }
400 )
401 except sqlite3.OperationalError:
402 pass
403 return branches
404
405 def get_tags(self) -> list[dict]:
406 """Get all tags (non-branch sym- tags that mark specific checkins)."""
407 tags = []
408 try:
409 rows = self.conn.execute(
410 """
411 SELECT tag.tagname, event.mtime, event.user, blob.uuid
412 FROM tag
413 JOIN tagxref ON tag.tagid = tagxref.tagid AND tagxref.value > 0
414 JOIN event ON tagxref.rid = event.objid
415 JOIN blob ON event.objid = blob.rid
416 WHERE tag.tagname LIKE 'sym-%'
417 AND tag.tagname NOT IN (SELECT tagname FROM tag JOIN tagxref ON tag.tagid=tagxref.tagid GROUP BY tagname HAVING count(*) > 5)
418 ORDER BY event.mtime DESC
419 LIMIT 100
420 """,
421 ).fetchall()
422 for r in rows:
423 tags.append(
424 {
425 "name": r["tagname"].replace("sym-", "", 1),
426 "timestamp": _julian_to_datetime(r["mtime"]),
427 "user": r["user"] or "",
428 "uuid": r["uuid"],
429 }
430 )
431 except sqlite3.OperationalError:
432 pass
433 return tags
434
435 def get_repo_statistics(self) -> dict:
436 """Get comprehensive repository statistics."""
437 stats = {}
438 try:
439 stats["total_artifacts"] = self.conn.execute("SELECT count(*) FROM blob").fetchone()[0]
440 stats["total_events"] = self.conn.execute("SELECT count(*) FROM event").fetchone()[0]
441 stats["checkin_count"] = self.conn.execute("SELECT count(*) FROM event WHERE type='ci'").fetchone()[0]
442 stats["wiki_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='w'").fetchone()[0]
443 stats["ticket_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='t'").fetchone()[0]
444 stats["forum_events"] = self.conn.execute("SELECT count(*) FROM event WHERE type='f'").fetchone()[0]
445
446 # First and last checkin dates
447 first = self.conn.execute("SELECT min(mtime) FROM event WHERE type='ci'").fetchone()
448 last = self.conn.execute("SELECT max(mtime) FROM event WHERE type='ci'").fetchone()
449 if first and first[0]:
450 stats["first_checkin"] = _julian_to_datetime(first[0])
451 if last and last[0]:
452 stats["last_checkin"] = _julian_to_datetime(last[0])
453
454 # Unique contributors
455 stats["contributors"] = self.conn.execute("SELECT count(DISTINCT user) FROM event WHERE type='ci'").fetchone()[0]
456
457 # DB size
458 stats["db_pages"] = self.conn.execute("PRAGMA page_count").fetchone()[0]
459 stats["page_size"] = self.conn.execute("PRAGMA page_size").fetchone()[0]
460 stats["db_size_mb"] = round((stats["db_pages"] * stats["page_size"]) / (1024 * 1024), 1)
461 except sqlite3.OperationalError:
462 pass
463 return stats
464
465 # --- Timeline ---
466
467 def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
468 sql = """
469 SELECT blob.rid, blob.uuid, event.type, event.mtime, event.user, event.comment
470 FROM event
471 JOIN blob ON event.objid = blob.rid
472 """
473 params: list = []
474 if event_type:
475 sql += " WHERE event.type = ?"
476 params.append(event_type)
477 sql += " ORDER BY event.mtime DESC LIMIT ? OFFSET ?"
478 params.extend([limit, offset])
479
480 entries = []
481 try:
482 for row in self.conn.execute(sql, params):
483 branch = ""
484 parent_rid = 0
485 is_merge = False
486
487 try:
488 br = self.conn.execute(
489 "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid "
490 "WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
491 (row["rid"],),
492 ).fetchone()
493 if br:
494 branch = br[0].replace("sym-", "", 1)
495 except sqlite3.OperationalError:
496 pass
497
498 # Get parent info from pli_rids = []
499 if row["type"] == "ci":
500 try:
501 parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
502 for p in parents:
503 if p["isprim"]:
504 parent_rid = p["pid-- Timeline ---
505
506 def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
507 sql = """
508 SELECT blob.rid, blob.uuid, event.type, event.mtime, event.user, event.comment
509 FROM event
510 JOIN blob ON event.objid = blob.rid
511 """
512 params: list = []
513 if event_type:
514 sql += " WHERE event.type = ?"
515 params.append(event_type)
516 sql += " ORDER BY event.mtime DESC LIMIT ? OFFSET ?"
517 params.extend([limit, offset])
518
519 entries = []
520 try:
521 ["tkt_id" br = self.conn.execute(
522 "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid "
523 "WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
524 (row["rid"],),
525 ).fetchone()
526 if br:
527 branch = br[0].replace("sym-", "", 1)
528 except sqlite3.OperationalError:
529 pass
530
531 # Get parent info from plink for DAG
532 merge_parent_rids = []
533 if row["type"] == "ci":
534 try:
535 parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
536 for p in parents:
537 if p["isprim"]:
538 parent_rid = p["pid"]
539 else:
540 merge_parent_rids.append(p["pid"])
541 is_merge = len(parents) > 1
542 except sqlite3.OperationalError:
543 pass
544
545 entries.append(
546 TimelineEntry(
547 rid=row["rid"],
548 uuid=row["uuid"],
549 event_type=row["type"],
550 timestamp=_julian_to_datetime(row["mtime"]),
551 user=row["user"] or "",
552 comment=row["comment"] or "",
553 branch=branch,
554 parent_rid=parent_rid,
555 is_merge=is_merge,
556 merge_parent_rids=merge_parent_rids,
557 )
558 )
559 except sqlite3.OperationalError:
560 pass
561
562 # Assign rail positions based on branches
563 branch_rails: dict[str, int] = {}
564 next_rail = 0
565 for entry in entries:
566 if entry.event_type != "ci":
567 entry.rail = -1 # non-checkin events don't get a rail
568 continue
569 b = entry.branch or "trunk"
570 if b not in branch_rails:
571 branch_rails[b] = next_rail
572 next_rail += 1
573 entry.rail = branch_rails[b]
574
575 return entries
576
577 # --- Checkin Detail ---
578
579 def get_checkin_detail(self, uuid: str) -> CheckinDetail | None:
580 """Get full details for a specific checkin, including changed files."""
581 try:
582 row = self.conn.execute(
583 "SELECT blob.rid, blob.uuid, event.mtime, event.user, event.comment "
584 "FROM event JOIN blob ON event.objid=blob.rid "
585 "WHERE blob.uuid LIKE ? AND event.type='ci'",
586 (uuid + "%",),
587 ).fetchone()
588 if not row:
589 return None
590
591 rid = row["rid"]
592 full_uuid = row["uuid"]
593
594 # Get branch
595 branch = ""
596 try:
597 br = self.conn.execute(
598 "SELECT tag.tagname FROM tagxref JOIN tag ON tagxref.tagid=tag.tagid WHERE tagxref.rid=? AND tag.tagname LIKE 'sym-%'",
599 (rid,),
600 ).fetchone()
601 if br:
602 branch = br[0].replace("sym-", "", 1)
603 except sqlite3.OperationalError:
604 pass
605
606 # Get parent
607 parent_uuid = ""
608 is_merge = False
609 try:
610 parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (rid,)).fetchall()
611 for p in parents:
612 if p["isprim"]:
613 parent_row = self.conn.execute("SELECT uuid FROM blob WHERE rid=?", (p["pid"],)).fetchone()
614 if parent_row:
615 parent_uuid = parent_row["uuid"]
616 is_merge = len(parents) > 1
617 except sqlite3.OperationalError:
618 pass
619
620 # Get changed files from mlink
621 files_changed = []
622 try:
623 mlinks = self.conn.execute(
624 """
625 SELECT fn.name, ml.fid, ml.pid,
626 b_new.uuid as new_uuid,
627 b_old.uuid as old_uuid
628 FROM mlink ml
629 JOIN filename fn ON ml.fnid = fn.fnid
630 LEFT JOIN blob b_new ON ml.fid = b_new.rid
631 LEFT JOIN blob b_old ON ml.pid = b_old.rid
632 WHERE ml.mid = ?
633 ORDER BY fn.name
634 """,
635 (rid,),
636 ).fetchall()
637 for ml in mlinks:
638 if ml["fid"] == 0:
639 change_type = "deleted"
640 elif ml["pid"] == 0:
641 change_type = "added"
642 else:
643 change_type = "modified"
644 files_changed.append(
645 {
646 "name": ml["name"],
647 "change_type": change_type,
648 "uuid": ml["new_uuid"] or "",
649 "prev_uuid": ml["old_uuid"] or "",
650 }
651 )
652 except sqlite3.OperationalError:
653 pass
654
655 return CheckinDetail(
656 uuid=full_uuid,
657 timestamp=_julian_to_datetime(row["mtime"]),
658 user=row["user"] or "",
659 comment=row["comment"] or "",
660 branch=branch,
661 parent_uuid=parent_uuid,
662 is_merge=is_merge,
663 files_changed=files_changed,
664 )
665 except sqlite3.OperationalError:
666 return None
667
668 # --- Code / Files ---
669
670 def get_latest_checkin_uuid(self) -> str | None:
671 try:
672 row = self.conn.execute(
673 "SELECT blob.uuid FROM event JOIN blob ON event.objid=blob.rid WHERE event.type='ci' ORDER BY event.mtime DESC LIMIT 1"
674 ).fetchone()
675 return row[0] if row else None
676 except sqlite3.OperationalError:
677 return None
678
679 def get_files_at_checkin(self, checkin_uuid: str | None = None) -> list[FileEntry]:
680 """Get the cumulative file list at a given checkin, with last commit info per file."""
681 if checkin_uuid is None:
682 checkin_uuid = self.get_latest_checkin_uuid()
683 if not checkin_uuid:
684 return []
685
686 try:
687 # Build cumulative file state: for each filename, find the latest mlink entry
688 # where fid > 0 (fid=0 means file was deleted)
689 rows = self.conn.execute(
690 """
691 SELECT fn.name, b.uuid, b.size,
692 e.comment, e.user, e.mtime
693 FROM (
694 SELECT ml.fnid, ml.fid,
695 MAX(e2.mtime) as max_mtime
696 FROM mlink ml
697 JOIN event e2 ON ml.mid = e2.objid
698 WHERE e2.type = 'ci'
699 GROUP BY ml.fnid
700 ) latest
701 JOIN mlink ml2 ON ml2.fnid = latest.fnid
702 JOIN event e ON ml2.mid = e.objid AND e.mtime = latest.max_mtime AND e.type = 'ci'
703 JOIN filename fn ON latest.fnid = fn.fnid
704 LEFT JOIN blob b ON ml2.fid = b.rid
705 WHERE ml2.fid > 0
706 ORDER BY fn.name
707 """,
708 ).fetchall()
709
710 return [
711 FileEntry(
712 name=r["name"],
713 uuid=r["uuid"] or "",
714 size=r["size"] or 0,
715 last_commit_message=r["comment"] or "",
716 last_commit_user=r["user"] or "",
717 last_commit_time=_julian_to_datetime(r["mtime"]) if r["mtime"] else None,
718 )
719 for r in rows
720 ]
721 except sqlite3.OperationalError:
722 return []
723
724 def get_file_content(self, blob_uuid: str) -> bytes:
725 """Get file content, resolving delta compression chains."""
726 try:
727 return self._resolve_blob(blob_uuid)
728 except Exception:
729 return b""
730
731 def _resolve_blob(self, uuid_or_rid, by_rid=False) -> bytes:
732 """Resolve a blob, following delta chains if needed."""
733 if by_rid:
734 row = self.conn.execute("SELECT rid, content FROM blob WHERE rid=?", (uuid_or_rid,)).fetchone()
735 else:
736 row = self.conn.execute("SELECT rid, content FROM blob WHERE uuid=?", (uuid_or_rid,)).fetchone()
737 if not row or not row["content"]:
738 return b""
739
740 rid = row["rid"]
741 data = _decompress_blob(row["content"])
742
743 # Check if this blob is delta-compressed
744 delta_row = self.conn.execute("SELECT srcid FROM delta WHERE rid=?", (rid,)).fetchone()
745 if delta_row:
746 # Recursively resolve the source blob
747 source = self._resolve_blob(delta_row["srcid"], by_rid=True)
748 return _apply_fossil_delta(source, data)
749
750 return data
751
752 def get_file_history(self, filename: str, limit: int = 50) -> list[dict]:
753 """Get commit history for a specific file."""
754 history = []
755 try:
756 rows = self.conn.execute(
757 """
758 SELECT blob.uuid, event.mtime, event.user, event.comment
759 FROM mlink ml
760 JOIN filename fn ON ml.fnid = fn.fnid
761 JOIN event ON ml.mid = event.objid
762 JOIN blob ON event.objid = blob.rid
763 WHERE fn.name = ? AND event.type = 'ci'
764 ORDER BY event.mtime DESC
765 LIMIT ?
766 """,
767 (filename, limit),
768 ).fetchall()
769 for r in rows:
770 history.append(
771 {
772 "uuid": r["uuid"],
773 "timestamp": _julian_to_datetime(r["mtime"]),
774 "user": r["user"] or "",
775 "comment": r["comment"] or "",
776 }
777 )
778 except sqlite3.OperationalError:
779 pass
780 return history
781
782 def search(self, query: str, limit: int = 50) -> dict:
783 """Search across checkins, tickets, and wiki pages."""
784 results = {"checkins": [], "tickets": [], "wiki": []}
785 q = f"%{query}%"
786 try:
787 # Search checkin comments
788 rows = self.conn.execute(
789 "SELECT blob.uuid, event.mtime, event.user, event.comment FROM event "
790 "JOIN blob ON event.objid=blob.rid WHERE event.type='ci' AND event.comment LIKE ? "
791 "ORDER BY event.mtime DESC LIMIT ?",
792 (q, limit),
793 ).fetchall()
794 for r in rows:
795 results["checkins"].append(
796 {
797 "uuid": r["uuid"],
798 "timestamp": _julian_to_datetime(r["mtime"]),
799 "user": r["user"] or "",
800 "comment": r["comment"] or "",
801 }
802 )
803 except sqlite3.OperationalError:
804 pass
805 try:
806 # Search ticket titles
807 rows = self.conn.execute(
808 "SELECT tkt_uuid, title, status, tkt_ctime FROM ticket WHERE title LIKE ? ORDER BY tkt_ctime DESC LIMIT ?",
809 (q, limit),
810 ).fetchall()
811 for r in rows:
812 results["tickets"].append(
813 {
814 "uuid": r["tkt_uuid"],
815 "title": r["title"] or "",
816 "status": r["status"] or "",
817 "created": _julian_to_datetime(r["tkt_ctime"]) if r["tkt_ctime"] else None,
818 }
819 )
820 except sqlite3.OperationalError:
821 pass
822 try:
823 # Search wiki page names
824 rows = self.conn.execute(
825 "SELECT DISTINCT substr(tagname, 6) as name FROM tag WHERE tagname LIKE ? ORDER BY name LIMIT ?",
826 (f"wiki-%{query}%", limit),
827 ).fetchall()
828 for r in rows:
829 results["wiki"].append({"name": r["name"]})
830 except sqlite3.OperationalError:
831 pass
832 return results
833
834 # --- Tickets ---
835
836 def get_tickets(self, status: str | None = None, limit: int = 50) -> list[TicketEntry]:
837 sql = "SELECT tkt_uuid, title, status, type, tkt_ctime, subsystem, priority FROM ticket"
838 params: list = []
839 if status:
840 sql += " WHERE status = ?"
841 params.append(status)
842 sql += " ORDER BY tkt_ctime DESC LIMIT ?"
843 params.append(limit)
844
845 entries = []
846 try:
847 for row in self.conn.execute(sql, params):
848 entries.append(
849 TicketEntry(
850 uuid=row["tkt_uuid"] or "",
851 title=row["title"] or "",
852 status=row["status"] or "",
853 type=row["type"] or "",
854 created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
855 owner="",
856 subsystem=row["subsystem"] or "",
857 priority=row["priority"] or "",
858 )
859 )
860 except sqlite3.OperationalError:
861 pass
862 return entries
863
864 def get_ticket_detail(self, uuid: str) -> TicketEntry | None:
865 try:
866 row = self.conn.execute(
867 "SELECT tkt_id, tkt_uuid, title, status, type, tkt_ctime, subsystem, priority, severity, resolution, comment "
868 "FROM ticket WHERE tkt_uuid LIKE ?",
869 (uuid + "%",),
870 ).fetchone()
871 if not row:
872 return None
873
874 body = row["comment"] or ""
875
876 # If comment is empty, try ticketchng.icomment (newer Fossil stores descriptions there)
877 if not body:
878 try:
879 chng = self.conn.execute(
880 "SELECT icomment, login FROM ticketchng WHERE tkt_id=? ORDER BY tkt_mtime ASC LIMIT 1",
881 (row["tkt_id"],),
882 ).fetchone()
883 if chng and chng["icomment"]:
884 body = chng["icomment"]
885 except sqlite3.OperationalError:
886 pass
887
888 return TicketEntry(
889 uuid=row["tkt_uuid"],
890 title=row["title"] or "",
891 status=row["status"] or "",
892 type=row["type"] or "",
893 created=_julian_to_datetime(row["tkt_ctime"]) if row["tkt_ctime"] else datetime.now(UTC),
894 owner="",
895 subsystem=row["subsystem"] or "",
896 priority=row["priority"] or "",
897 severity=row["severity"] or "",
898 resolution=row["resolution"] or "",
899 body=body,
900 )
901 except sqlite3.OperationalError:
902 return None
903
904 def get_ticket_comments(self, uuid: str) -> list[dict]:
905 """Get all comments/changes for a ticket."""
906 comments = []
907 try:
908 row = self.conn.execute("SELECT tkt_id FROM ticket WHERE tkt_uuid LIKE ?", (uuid + "%",)).fetchone()
909 if not row:
910 return []
911 rows = self.conn.execute(
912 "SELECT tkt_mtime, login, username, icomment, mimetype FROM ticketchng WHERE tkt_id=? ORDER BY tkt_mtime ASC",
913 (row["tkt_id"],),
914 ).fetchall()
915 for r in rows:
916 if r["icomment"]:
917 comments.append(
918 {
919 "timestamp": _julian_to_datetime(r["tkt_mtime"]) if r["tkt_mtime"] else None,
920 "user": r["username"] or r["login"] or "",
921 "comment": r["icomment"],
922 "mimetype": r["mimetype"] or "text/plain",
923 }
924 )
925 except sqlite3.OperationalError:
926 pass
927 return comments
928
929 # --- Wiki ---
930
931 def get_wiki_pages(self) -> list[WikiPage]:
932 pages = []
933 try:
934 rows = self.conn.execute(
935 """
936 SELECT substr(tag.tagname, 6) as name, event.mtime, event.userly interface to F"""Read-only interface to Fossil's SQLite database.
937
938 Each .fossil file is a SQLite database containing all repo data:
939 code, timeline, tickets, wiki, forum. T WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w'
940 GROUP BY tag.tagname
941 HAVING event.mtime = MAX(event.mtime)
942 ORDER BY event.mtime DESC
943 """
944 ).fetchall()
945 for ropagser=? AND type='f'", (username, (row["tkt_id"],),
946 ).fetchall()
947 for r in rows:
948 if r["icomment"]:
949 comments.append(
950 {
951 "timestamp": _julian_to_datetime(r["tkt_mtime"]) if r["tkt_mtime"] else None,
952 "user": r["username"] or r["login"] or "",
953 "comment": r["icomment"],
954 "mimetype": r["mimetype"] or "text/plain",
955 }
956 )
957 except sqlite3.OperationalError:
958 pass
959 return comments
960
961 # --- Wiki ---
962
963 def get_wiki_pages(self) -> list[WikiPage]:
964 pages = []
965 try:
966 rows = self.conn.execute(
967 """
968 SELECT substr(tag.tagname, 6) as name, event.mtime, event.user,
969 blob.size as content_size
970 FROM tag
971 JOIN tagxref ON tag.tagid = tagxref.tagid
972 JOIN event ON tagxref.rid = event.objid
973 JOIN blob ON event.objid = blob.rid
974 WHERE tag.tagname LIKE 'wiki-%' AND event.type = 'w'
975 GROUP BY tag.tagname
976 HAVING e
--- a/fossil/signals.py
+++ b/fossil/signals.py
@@ -0,0 +1,42 @@
1
+"""Auto-create FossilRepository when a Project is created."""
2
+
3
+import logging
4
+
5
+from django.db.models.signals import post_save
6
+from django.dispatch import receiver
7
+
8
+from projects.models import Project
9
+
10
+logger = logging.getLogger(__name__)
11
+
12
+
13
+@receiver(post_save, sender=Project)
14
+def create_fossil_repo(sender, instance, created, **kwargs):
15
+ """When a new Project is created, create a FossilRepository record and init the .fossil file."""
16
+ if not created:
17
+ return
18
+
19
+ from fossil.models import FossilRepository
20
+
21
+ if FossilRepository.objects.filter(project=instance).exists():
22
+ return
23
+
24
+ filename = f"{instance.slug}.fossil"
25
+ repo = FossilRepository.objects.create(
26
+ project=instance,
27
+ filename=filename,
28
+ created_by=instance.created_by,
29
+ )
30
+
31
+ # Try to init the .fossil file on disk
32
+ try:
33
+dated_at:
34
+ try:
35
+cli = FossilCLI()
36
+ = FossilCLI()
37
+ if cli.icli.init(rrepo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0
38
+ isk else 0
39
+ repo.save(update_fields=["file_size_bytes", "updated_atelse:
40
+ logger.except Exception:
41
+ ept Exception:
42
+ logger.
--- a/fossil/signals.py
+++ b/fossil/signals.py
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/signals.py
+++ b/fossil/signals.py
@@ -0,0 +1,42 @@
1 """Auto-create FossilRepository when a Project is created."""
2
3 import logging
4
5 from django.db.models.signals import post_save
6 from django.dispatch import receiver
7
8 from projects.models import Project
9
10 logger = logging.getLogger(__name__)
11
12
13 @receiver(post_save, sender=Project)
14 def create_fossil_repo(sender, instance, created, **kwargs):
15 """When a new Project is created, create a FossilRepository record and init the .fossil file."""
16 if not created:
17 return
18
19 from fossil.models import FossilRepository
20
21 if FossilRepository.objects.filter(project=instance).exists():
22 return
23
24 filename = f"{instance.slug}.fossil"
25 repo = FossilRepository.objects.create(
26 project=instance,
27 filename=filename,
28 created_by=instance.created_by,
29 )
30
31 # Try to init the .fossil file on disk
32 try:
33 dated_at:
34 try:
35 cli = FossilCLI()
36 = FossilCLI()
37 if cli.icli.init(rrepo.file_size_bytes = repo.full_path.stat().st_size if repo.exists_on_disk else 0
38 isk else 0
39 repo.save(update_fields=["file_size_bytes", "updated_atelse:
40 logger.except Exception:
41 ept Exception:
42 logger.
--- a/fossil/tasks.py
+++ b/fossil/tasks.py
@@ -0,0 +1,117 @@
1
+"""Celery tasks for Fossil repository management."""
2
+
3
+import hashlib
4
+import logging
5
+
6
+from celery import shared_task
7
+from django.core.files.base import ContentFile
8
+
9
+logger = logging.getLogger(__name__)
10
+
11
+
12
+@shared_task(name="fossil.sync_metadata")
13
+def sync_repository_metadata():
14
+ """Update metadata for all FossilRepository records from disk."""
15
+ from fossil.models import FossilRepository
16
+ from fossil.reader import FossilReader
17
+
18
+ for repo in FossilRepository.objects.all():
19
+ if not repo.exists_on_disk:
20
+ continue
21
+ try:
22
+ repo.file_size_bytes = repo.full_path.stat().st_size
23
+ with FossilReader(repo.full_path) as reader:
24
+ repo.checkin_count = reader.get_checkin_count()
25
+ timeline = reader.get_timeline(limit=1)
26
+ if timeline:
27
+ repo.last_checkin_at = timeline[0].timestamp
28
+ repo.fossil_project_code = reader.get_project_code()
29
+ repo.save(update_fields=["file_size_bytes", "checkin_count", "last_checkin_at", "fossil_project_code", "updated_at", "version"])
30
+ except Exception:
31
+ logger.exception("Failed to sync metadata for %s", repo.filename)
32
+
33
+
34
+@shared_task(name="fossil.create_snapshot")
35
+def create_snapshot(repository_id: int, note: str = ""):
36
+ """Create a FossilSnapshot if FOSSIL_STORE_IN_DB is enabled."""
37
+ from constance import config
38
+
39
+ if not config.FOSSIL_STORE_IN_DB:
40
+ return
41
+
42
+ from fossil.models import FossilRepository, FossilSnapshot
43
+
44
+ try:
45
+ repo = FossilRepository.objects.get(pk=repository_id)
46
+ except FossilRepository.DoesNotExist:
47
+ return
48
+
49
+ if not repo.exists_on_disk:
50
+ return
51
+
52
+ data = repo.full_path.read_bytes()
53
+ sha = hashlib.sha256(data).hexdigest()
54
+
55
+ # Skip if latest snapshot has same hash
56
+ latest = repo.snapshots.first()
57
+ if latest and latest.fossil_hash == sha:
58
+ return
59
+
60
+ snapshot = FossilSnapshot(
61
+ repository=repo,
62
+ file_size_bytes=len(data),
63
+ fossil_hash=sha,
64
+ note=note,
65
+ created_by=repo.created_by,
66
+ )
67
+ snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True)
68
+ logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8])
69
+
70
+
71
+@shared_task(name="fossil.check_upstream")
72
+def check_upstream_updates():
73
+ """Check all repos with remote URLs for available updates."""
74
+ from fossil.cli import FossilCLI
75
+ from fossil.models import FossilRepository
76
+
77
+ cli = FossilCLI()
78
+ if not cli.is_available():
79
+ return
80
+
81
+ from django.utils import timezone
82
+
83
+ for repo in FossilRepository.objects.exclude(remote_url=""):
84
+ if not repo.exists_on_disk:
85
+ continue
86
+ try:
87
+ result = cli.pull(repo.full_path)
88
+ if result["success"] and result["artifacts_received"] > 0:
89
+ repo.upstream_artifacts_available = result["artifacts_received"]
90
+ repo.last_sync_at = timezone.now()
91
+ # Update metadata after pull
92
+ from fossil.reader import FossilReader
93
+
94
+ with FossilReader(repo.full_path) as reader:
95
+ repo.checkin_count = reader.get_checkin_count()
96
+ timeline = reader.get_timeline(limit=1, event_type="ci")
97
+ if timeline:
98
+ repo.last_checkin_at = timeline[0].timestamp
99
+ repo.file_size_bytes = repo.full_path.stat().st_size
100
+ repo.save(
101
+ update_fields=[
102
+ "upstream_artifacts_available",
103
+ "last_sync_at",
104
+ "checkin_count",
105
+ "last_checkin_at",
106
+ "file_size_bytes",
107
+ "updated_at",
108
+ "version",
109
+ ]
110
+ )
111
+ logger.info("Pulled %d artifacts for %s (new count: %d)", result["artifacts_received"], repo.filename, repo.checkin_count)
112
+ else:
113
+ repo.upstream_artifacts_available = 0
114
+ repo.last_sync_at = timezone.now()
115
+ repo.save(update_fields=["upstream_artifacts_available", "last_sync_at", "updated_at", "version"])
116
+ except Exception:
117
+ logger.exception("Failed to check upstream for %s", repo.filename)
--- a/fossil/tasks.py
+++ b/fossil/tasks.py
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/tasks.py
+++ b/fossil/tasks.py
@@ -0,0 +1,117 @@
1 """Celery tasks for Fossil repository management."""
2
3 import hashlib
4 import logging
5
6 from celery import shared_task
7 from django.core.files.base import ContentFile
8
9 logger = logging.getLogger(__name__)
10
11
12 @shared_task(name="fossil.sync_metadata")
13 def sync_repository_metadata():
14 """Update metadata for all FossilRepository records from disk."""
15 from fossil.models import FossilRepository
16 from fossil.reader import FossilReader
17
18 for repo in FossilRepository.objects.all():
19 if not repo.exists_on_disk:
20 continue
21 try:
22 repo.file_size_bytes = repo.full_path.stat().st_size
23 with FossilReader(repo.full_path) as reader:
24 repo.checkin_count = reader.get_checkin_count()
25 timeline = reader.get_timeline(limit=1)
26 if timeline:
27 repo.last_checkin_at = timeline[0].timestamp
28 repo.fossil_project_code = reader.get_project_code()
29 repo.save(update_fields=["file_size_bytes", "checkin_count", "last_checkin_at", "fossil_project_code", "updated_at", "version"])
30 except Exception:
31 logger.exception("Failed to sync metadata for %s", repo.filename)
32
33
34 @shared_task(name="fossil.create_snapshot")
35 def create_snapshot(repository_id: int, note: str = ""):
36 """Create a FossilSnapshot if FOSSIL_STORE_IN_DB is enabled."""
37 from constance import config
38
39 if not config.FOSSIL_STORE_IN_DB:
40 return
41
42 from fossil.models import FossilRepository, FossilSnapshot
43
44 try:
45 repo = FossilRepository.objects.get(pk=repository_id)
46 except FossilRepository.DoesNotExist:
47 return
48
49 if not repo.exists_on_disk:
50 return
51
52 data = repo.full_path.read_bytes()
53 sha = hashlib.sha256(data).hexdigest()
54
55 # Skip if latest snapshot has same hash
56 latest = repo.snapshots.first()
57 if latest and latest.fossil_hash == sha:
58 return
59
60 snapshot = FossilSnapshot(
61 repository=repo,
62 file_size_bytes=len(data),
63 fossil_hash=sha,
64 note=note,
65 created_by=repo.created_by,
66 )
67 snapshot.file.save(f"{repo.filename}_{sha[:8]}.fossil", ContentFile(data), save=True)
68 logger.info("Created snapshot for %s (hash: %s)", repo.filename, sha[:8])
69
70
71 @shared_task(name="fossil.check_upstream")
72 def check_upstream_updates():
73 """Check all repos with remote URLs for available updates."""
74 from fossil.cli import FossilCLI
75 from fossil.models import FossilRepository
76
77 cli = FossilCLI()
78 if not cli.is_available():
79 return
80
81 from django.utils import timezone
82
83 for repo in FossilRepository.objects.exclude(remote_url=""):
84 if not repo.exists_on_disk:
85 continue
86 try:
87 result = cli.pull(repo.full_path)
88 if result["success"] and result["artifacts_received"] > 0:
89 repo.upstream_artifacts_available = result["artifacts_received"]
90 repo.last_sync_at = timezone.now()
91 # Update metadata after pull
92 from fossil.reader import FossilReader
93
94 with FossilReader(repo.full_path) as reader:
95 repo.checkin_count = reader.get_checkin_count()
96 timeline = reader.get_timeline(limit=1, event_type="ci")
97 if timeline:
98 repo.last_checkin_at = timeline[0].timestamp
99 repo.file_size_bytes = repo.full_path.stat().st_size
100 repo.save(
101 update_fields=[
102 "upstream_artifacts_available",
103 "last_sync_at",
104 "checkin_count",
105 "last_checkin_at",
106 "file_size_bytes",
107 "updated_at",
108 "version",
109 ]
110 )
111 logger.info("Pulled %d artifacts for %s (new count: %d)", result["artifacts_received"], repo.filename, repo.checkin_count)
112 else:
113 repo.upstream_artifacts_available = 0
114 repo.last_sync_at = timezone.now()
115 repo.save(update_fields=["upstream_artifacts_available", "last_sync_at", "updated_at", "version"])
116 except Exception:
117 logger.exception("Failed to check upstream for %s", repo.filename)
--- a/fossil/tests.py
+++ b/fossil/tests.py
@@ -0,0 +1,164 @@
1
+import shutifrom unittest.moctil
2
+from import UTC, datetime
3
+from pathlib import Path
4
+
5
+import pytest
6
+
7
+from .models impossilReader, Time
8
+# --- Reader tests ---
9
+
10
+
11
+@pytest.mark.django_db
12
+class TestFossilReader:
13
+ @pytest.fixture
14
+ def repo_path(self, tmp_path):
15
+ src = Path("/tmp/fossil-setup/frontend-app.fossil")
16
+ if not src.exists():
17
+ pytest.skip("Test fossil repo not available")
18
+ dest = tmp_path / "test.fossil"
19
+ shutil.copy2(src, dest)
20
+ return dest
21
+
22
+ def test_get_metadata(self, repo_path):
23
+ with FossilReader(repo_path) as reader:
24
+ meta = reader.get_metadata()
25
+ assert meta.checkin_count >= 2
26
+
27
+ def test_get_timeline(self, repo_path):
28
+ with FossilReader(repo_path) as reader:
29
+ entries = reader.get_timeline(limit=10)
30
+ assert len(entries) > 0
31
+ assert entries[0].uuid
32
+ assert entries[0].user
33
+
34
+ def test_get_timeline_filter_by_type(self, repo_path):
35
+ with FossilReader(repo_path) as reader:
36
+ checkins = reader.get_timeline(limit=10, event_type="ci")
37
+ for e in checkins:
38
+ assert e.event_type == "ci"
39
+
40
+ def test_get_latest_checkin_uuid(self, repo_path):
41
+ with FossilReader(repo_path) as reader:
42
+ uuid = reader.get_latest_checkin_uuid()
43
+ assert uuid is not None
44
+ assert len(uuid) > 10
45
+
46
+ def test_get_files_at_checkin(self, repo_path):
47
+ with FossilReader(repo_path) as reader:
48
+ files = reader.get_files_at_checkin()
49
+ assert len(files) > 0
50
+ names = [f.name for f in files]
51
+ assert any("README" in n or "index" in n or "utils" in n for n in names)
52
+
53
+ def test_get_file_content(self, repo_path):
54
+ with FossilReader(repo_path) as reader:
55
+ files = reader.get_files_at_checkin()
56
+ if files:
57
+ content = reader.get_file_content(files[0].uuid)
58
+ assert len(content) > 0
59
+
60
+ def test_get_wiki_pages(self, repo_path):
61
+ with FossilReader(repo_path) as reader:
62
+ pages = reader.get_wiki_pages()
63
+ assert len(pages) >= 2
64
+ names = [p.name for p in pages]
65
+ assert "Home" in names
66
+
67
+ def test_get_wiki_page_content(self, repo_path):
68
+ with FossilReader(repo_path) as reader:
69
+ page = reader.get_wiki_page("Home")
70
+ assert page is not None
71
+ assert len(page.content) > 0
72
+
73
+ def test_get_checkin_detail(self, repo_path):
74
+ with FossilReader(repo_path) as reader:
75
+ uuid = reader.get_latest_checkin_uuid()
76
+ detail = reader.get_checkin_detail(uuid[:8])
77
+ assert detail is not None
78
+ assert detail.uuid == uuid
79
+ assert detail.comment
80
+ assert len(detail.files_changed) > 0
81
+
82
+ def test_get_commit_activity(self, repo_path):
83
+ with FossilReader(repo_path) as reader:
84
+ activity = reader.get_commit_activity(weeks=4)
85
+ assert len(activity) == 4
86
+ total = sum(a["count"] for a in activity)
87
+ assert total > 0
88
+
89
+ def test_get_user_activity(self, repo_path):
90
+ with FossilReader(repo_path) as reader:
91
+ activity = reader.get_user_activity("ragelink")
92
+ assert activity["checkin_count"] > 0
93
+ assert len(activity["checkins"]) > 0
94
+
95
+
96
+# --- Helper function tests ---
97
+
98
+
99
+class TestExtractWikiContent:
100
+ def test_basic_extraction(self):
101
+ artifact = "D 2026-01-01T00:00:00\nL TestPage\nU user\nW 5\nhello\nZ abc123"
102
+ assert _extract_wiki_content(artifact) == "hello"
103
+
104
+ def test_multiline_content(self):
105
+ artifact = "D 2026-01-01T00:00:00\nL Page\nU user\nW 11\nhello\nworld\nZ abc123"
106
+ assert _extract_wiki_content(artifact) == "hello\nworld"
107
+
108
+ def test_empty_content(self):
109
+ artifact = "D 2026-01-01T00:00:00\nL Page\nU user\nW 0\n\nZ abc123"
110
+ assert _extract_wiki_content(artifact) == ""
111
+
112
+ def test_no_w_card(self):
113
+ assert _extract_wiki_content("just some text") == ""
114
+
115
+
116
+class TestApplyFossilDelta:
117
+ def test_copy_command(self):
118
+ source = b"Hello, World!"
119
+ # Delta: output size 5, copy 5 bytes from offset 0
120
+ # This is a simplified test
121
+ result = _apply_fossil_delta(source, b"")
122
+ assert result == source # empty delta returns source
123
+
124
+ def test_empty_delta(self):
125
+ source = b"test content"
126
+ result = _apply_fossil_delta(source, b"")
127
+ asserModel tests ---
128
+
129
+
130
+@pytest.mark.d _make_entry(rid=2, branch="feature", parent_rid=1, rail=1),
131
+ _make_entry(rid=1, parent_rid=0, rail=0),
132
+ ]
133
+ result = _compute_dag_graph(entries)
134
+ # At row index 1 (rid=3), both rail 0 and rail 1 should be active
135
+ # because rail 1 spans from index 0 (rid=4) to index 2 (rid=2)
136
+ # and rail 0 spans from index 1 (rid=3) to index 3 (rid=1)
137
+ active_xs = {line["x"] for line in result[1]["lines"]}
138
+ rail_0_x = 20 + 0 * 16 # 20
139
+ rail_1_x = 20 + 1 * 16 # 36
140
+ _fals2, branch="feature", parent_rid=1 expected filename
141
+ repo = FossilRepository.objects.get(proje# The /data/repos di 0 * 16 # 20
142
+ rail_1_x = 20 + 1 * 16 # 36
143
+ assert rail_0_x in active_xs
144
+ assert rail_1_x in active_xs
145
+
146
+ def test_graph_width_accommodates_rails(self):
147
+ """Graph width should be wide enough for all rails plus padding."""
148
+ entries = [
149
+ _make_entry(rid=3, branch="b2", parent_rid=1, rail=2),
150
+ _make_entry(rid=2, branch="b1", parent_rid=1, rail=1),
151
+ _make_entry(rid=1, parent_rid=0, rail=0),
152
+ ]
153
+ result = _compute_dag_graph(entries)
154
+ # max_rail=2, graph_width = 20 + (2+2)*16 = 84
155
+ assert result[0]["graph_width"] == 84
156
+
157
+ def test_connector_geometry(self):
158
+ """Fork connector left and width should span from the lower rail to the higher rail."""
159
+ entries = [
160
+ _make_entry(rid=2, branch="feature", parent_rid=1, rail=2), # fork from rail 0
161
+ _make_entry(rid=1, parent_rid=0, rail=0),
162
+ ]
163
+ result = _compute_dag_graph(entries)
164
+
--- a/fossil/tests.py
+++ b/fossil/tests.py
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/tests.py
+++ b/fossil/tests.py
@@ -0,0 +1,164 @@
1 import shutifrom unittest.moctil
2 from import UTC, datetime
3 from pathlib import Path
4
5 import pytest
6
7 from .models impossilReader, Time
8 # --- Reader tests ---
9
10
11 @pytest.mark.django_db
12 class TestFossilReader:
13 @pytest.fixture
14 def repo_path(self, tmp_path):
15 src = Path("/tmp/fossil-setup/frontend-app.fossil")
16 if not src.exists():
17 pytest.skip("Test fossil repo not available")
18 dest = tmp_path / "test.fossil"
19 shutil.copy2(src, dest)
20 return dest
21
22 def test_get_metadata(self, repo_path):
23 with FossilReader(repo_path) as reader:
24 meta = reader.get_metadata()
25 assert meta.checkin_count >= 2
26
27 def test_get_timeline(self, repo_path):
28 with FossilReader(repo_path) as reader:
29 entries = reader.get_timeline(limit=10)
30 assert len(entries) > 0
31 assert entries[0].uuid
32 assert entries[0].user
33
34 def test_get_timeline_filter_by_type(self, repo_path):
35 with FossilReader(repo_path) as reader:
36 checkins = reader.get_timeline(limit=10, event_type="ci")
37 for e in checkins:
38 assert e.event_type == "ci"
39
40 def test_get_latest_checkin_uuid(self, repo_path):
41 with FossilReader(repo_path) as reader:
42 uuid = reader.get_latest_checkin_uuid()
43 assert uuid is not None
44 assert len(uuid) > 10
45
46 def test_get_files_at_checkin(self, repo_path):
47 with FossilReader(repo_path) as reader:
48 files = reader.get_files_at_checkin()
49 assert len(files) > 0
50 names = [f.name for f in files]
51 assert any("README" in n or "index" in n or "utils" in n for n in names)
52
53 def test_get_file_content(self, repo_path):
54 with FossilReader(repo_path) as reader:
55 files = reader.get_files_at_checkin()
56 if files:
57 content = reader.get_file_content(files[0].uuid)
58 assert len(content) > 0
59
60 def test_get_wiki_pages(self, repo_path):
61 with FossilReader(repo_path) as reader:
62 pages = reader.get_wiki_pages()
63 assert len(pages) >= 2
64 names = [p.name for p in pages]
65 assert "Home" in names
66
67 def test_get_wiki_page_content(self, repo_path):
68 with FossilReader(repo_path) as reader:
69 page = reader.get_wiki_page("Home")
70 assert page is not None
71 assert len(page.content) > 0
72
73 def test_get_checkin_detail(self, repo_path):
74 with FossilReader(repo_path) as reader:
75 uuid = reader.get_latest_checkin_uuid()
76 detail = reader.get_checkin_detail(uuid[:8])
77 assert detail is not None
78 assert detail.uuid == uuid
79 assert detail.comment
80 assert len(detail.files_changed) > 0
81
82 def test_get_commit_activity(self, repo_path):
83 with FossilReader(repo_path) as reader:
84 activity = reader.get_commit_activity(weeks=4)
85 assert len(activity) == 4
86 total = sum(a["count"] for a in activity)
87 assert total > 0
88
89 def test_get_user_activity(self, repo_path):
90 with FossilReader(repo_path) as reader:
91 activity = reader.get_user_activity("ragelink")
92 assert activity["checkin_count"] > 0
93 assert len(activity["checkins"]) > 0
94
95
96 # --- Helper function tests ---
97
98
99 class TestExtractWikiContent:
100 def test_basic_extraction(self):
101 artifact = "D 2026-01-01T00:00:00\nL TestPage\nU user\nW 5\nhello\nZ abc123"
102 assert _extract_wiki_content(artifact) == "hello"
103
104 def test_multiline_content(self):
105 artifact = "D 2026-01-01T00:00:00\nL Page\nU user\nW 11\nhello\nworld\nZ abc123"
106 assert _extract_wiki_content(artifact) == "hello\nworld"
107
108 def test_empty_content(self):
109 artifact = "D 2026-01-01T00:00:00\nL Page\nU user\nW 0\n\nZ abc123"
110 assert _extract_wiki_content(artifact) == ""
111
112 def test_no_w_card(self):
113 assert _extract_wiki_content("just some text") == ""
114
115
116 class TestApplyFossilDelta:
117 def test_copy_command(self):
118 source = b"Hello, World!"
119 # Delta: output size 5, copy 5 bytes from offset 0
120 # This is a simplified test
121 result = _apply_fossil_delta(source, b"")
122 assert result == source # empty delta returns source
123
124 def test_empty_delta(self):
125 source = b"test content"
126 result = _apply_fossil_delta(source, b"")
127 asserModel tests ---
128
129
130 @pytest.mark.d _make_entry(rid=2, branch="feature", parent_rid=1, rail=1),
131 _make_entry(rid=1, parent_rid=0, rail=0),
132 ]
133 result = _compute_dag_graph(entries)
134 # At row index 1 (rid=3), both rail 0 and rail 1 should be active
135 # because rail 1 spans from index 0 (rid=4) to index 2 (rid=2)
136 # and rail 0 spans from index 1 (rid=3) to index 3 (rid=1)
137 active_xs = {line["x"] for line in result[1]["lines"]}
138 rail_0_x = 20 + 0 * 16 # 20
139 rail_1_x = 20 + 1 * 16 # 36
140 _fals2, branch="feature", parent_rid=1 expected filename
141 repo = FossilRepository.objects.get(proje# The /data/repos di 0 * 16 # 20
142 rail_1_x = 20 + 1 * 16 # 36
143 assert rail_0_x in active_xs
144 assert rail_1_x in active_xs
145
146 def test_graph_width_accommodates_rails(self):
147 """Graph width should be wide enough for all rails plus padding."""
148 entries = [
149 _make_entry(rid=3, branch="b2", parent_rid=1, rail=2),
150 _make_entry(rid=2, branch="b1", parent_rid=1, rail=1),
151 _make_entry(rid=1, parent_rid=0, rail=0),
152 ]
153 result = _compute_dag_graph(entries)
154 # max_rail=2, graph_width = 20 + (2+2)*16 = 84
155 assert result[0]["graph_width"] == 84
156
157 def test_connector_geometry(self):
158 """Fork connector left and width should span from the lower rail to the higher rail."""
159 entries = [
160 _make_entry(rid=2, branch="feature", parent_rid=1, rail=2), # fork from rail 0
161 _make_entry(rid=1, parent_rid=0, rail=0),
162 ]
163 result = _compute_dag_graph(entries)
164
--- a/fossil/urls.py
+++ b/fossil/urls.py
@@ -0,0 +1,8 @@
1
+path("sync/git/caws.webhook_create, naback, views.webhook_create, name="callbackooks/<int:webhook_id_callbacke/", views.webhook_creatallbackname="webhook_edit"),
2
+ pathcallback, ate", api_views.api_workspace_create, name="api_workspace_create"),
3
+ path("api/workspaces/<str:workspace_name>", api_views.api_workspace_detail, name="api_workspace_detail"),
4
+ path("api/workspaces/<str:workspace_name>/commit", api_views.api_workspace_commit, name="appath("sync/git/calpath("webhooks/<k_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"),
5
+ path("user/<str:uame="tags"),
6
+ path("technotes/", views.technote_list, name="technotes"),
7
+ path("technotes/create/", views.technote_create, name="technote_create"),
8
+ path("technotes/<str:technote_id>/", views.technote_detail, name="technotcode/rawe_create, name="api_workspace_
--- a/fossil/urls.py
+++ b/fossil/urls.py
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
--- a/fossil/urls.py
+++ b/fossil/urls.py
@@ -0,0 +1,8 @@
1 path("sync/git/caws.webhook_create, naback, views.webhook_create, name="callbackooks/<int:webhook_id_callbacke/", views.webhook_creatallbackname="webhook_edit"),
2 pathcallback, ate", api_views.api_workspace_create, name="api_workspace_create"),
3 path("api/workspaces/<str:workspace_name>", api_views.api_workspace_detail, name="api_workspace_detail"),
4 path("api/workspaces/<str:workspace_name>/commit", api_views.api_workspace_commit, name="appath("sync/git/calpath("webhooks/<k_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"),
5 path("user/<str:uame="tags"),
6 path("technotes/", views.technote_list, name="technotes"),
7 path("technotes/create/", views.technote_create, name="technote_create"),
8 path("technotes/<str:technote_id>/", views.technote_detail, name="technotcode/rawe_create, name="api_workspace_
+1282
--- a/fossil/views.py
+++ b/fossil/views.py
@@ -0,0 +1,1282 @@
1
+nder
2
+)
3
+ - Fossil-spimport contextlib
4
+import re
5
+
6
+import markdown as md
7
+from django.contrib.auth.decorators import login_required
8
+from django.404
9
+ts import get_object_or_404, redirect, render
10
+from django.utils.s
11
+from procore.permissions import Pom projects.models import Project
12
+
13
+from .models import FossilRepository
14
+from .reader import FossilReader
15
+
16
+
17
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
18
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
19
+
20
+ Fossil wiki pages can contain:
21
+ - Raw HTML (most Fossil wiki pages)
22
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
23
+ - Markdown (newer pages)
24
+
25
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
26
+ """
27
+ if not content:
28
+ return ""
29
+
30
+ # Detect format from the raw content BEFORE any transformations
31
+ is_markdown = _is_markdown(content)
32
+
33
+ if is_markdown:
34
+ # Markdown: convert Fossil [path | text] links to markdown links first
35
+ def _fossil_to_md_link(m):
36
+ path = m.group(1).strip()
37
+ text = m.group(2).strip()
38
+ if path.startswith("./"):
39
+ path = "/" + base_path + path[2:]
40
+ elif not path.startswith("/") and not path.startswith("http"):
41
+ path = "/" + base_path + path
42
+ return f"[{text}]({path})"
43
+
44
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
45
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
46
+ html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
47
+
48
+ # Post-process: render pikchr fenced code blocks to SVG
49
+ def _render_pikchr_md(m):
50
+ try:
51
+ from fossil.cli import FossilCLI
52
+
53
+ cli = FossilCLI()
54
+ svg = cli.render_pikchr(m.group(1))
55
+ if svg:
56
+ return f'<div class="pikchr-diagram">{svg}</div>'
57
+ except Exception:
58
+ pass
59
+ return m.group(0)
60
+
61
+ html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
62
+ return _rewrite_fossil_links(html, project_slug) if project_slug else html
63
+
64
+ # Fossil wiki / HTML: convert Fossil-specific syntax to HTML
65
+ # Fossil links: [path | text] or [path|text] — spaces around pipe are optional
66
+ def _fossil_link_replace(match):
67
+ path = match.group(1).strip()
68
+ text = match.group(2).strip()
69
+ # Convert relative paths to absolute using base_path
70
+ if path.startswith("./"):
71
+ path = "/" + base_path + path[2:]
72
+ elif not path.startswith("/") and not path.startswith("http"):
73
+ path = "/" + base_path + path
74
+ return f'<a href="{path}">{text}</a>'
75
+
76
+ # Match [path | text] with flexible whitespace around the pipe
77
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
78
+ # Interwiki links: [wikipedia:Article] -> external link
79
+ content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
80
+ # Anchor links: [#anchor-name] -> local anchor
81
+ content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
82
+ # Bare wiki links: [PageName] (no pipe, not a URL)
83
+ content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
84
+
85
+ # Verbatim blocks
86
+ # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
87
+ def _render_pikchr_block(m):
88
+ try:
89
+ from fossil.cli import FossilCLI
90
+
91
+ cli = FossilCLI()
92
+ svg = cli.render_pikchr(m.group(1))
93
+ if svg:
94
+ return f'<div class="pikchr-diagram">{svg}</div>'
95
+ except Exception:
96
+ pass
97
+ return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
98
+
99
+ content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
100
+ # Regular verbatim blocks
101
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
102
+ # <nowiki> blocks — strip the tags, content passes through as-is
103
+ content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
104
+
105
+ # Convert Fossil wiki list syntax: * bullets and # enumeration
106
+ lines = content.split("\n")
107
+ result = []
108
+ in_list = False
109
+ list_type = "ul"
110
+ for line in lines:
111
+ stripped_line = line.strip()
112
+ is_bullet = re.match(r"^\*\s", stripped_line)
113
+ is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
114
+ if is_bullet or is_enum:
115
+ new_type = "ol" if is_enum else "ul"
116
+ if not in_list:
117
+ list_type = new_type
118
+ result.append(f"<{list_type}>")
119
+ in_list = True
120
+ elif new_type != list_type:
121
+ result.append(f"</{list_type}>")
122
+ list_type = new_type
123
+ result.append(f"<{list_type}>")
124
+ item_text = re.sub(r"^[\*#\d+\.\)]\s*", "", stripped_line)
125
+ result.append(f"<li>{item_text}</li>")
126
+ else:
127
+ if in_list:
128
+ result.append(f"</{list_type}>")
129
+ in_list = False
130
+ result.append(line)
131
+ if in_list:
132
+ result.append(f"</{list_type}>")
133
+
134
+ content = "\n".join(result)
135
+
136
+ # Wrap bare text blocks in <p> tags (lines not inside HTML tags)
137
+ content = re.sub(r"\n\n(?!<)", "\n\n<p>", content)
138
+
139
+ return _rewrite_fossil_links(content, project_slug) if project_slug else content
140
+
141
+
142
+def _is_markdown(content: str) -> bool:
143
+ """Detect if content is Markdown vs Fossil wiki/HTML.
144
+
145
+ Heuristic: if the content starts with markdown-style headings (#),
146
+ or has significant markdown syntax patterns, treat as markdown.
147
+ """
148
+ stripped = content.strip()
149
+ # Starts with markdown heading
150
+ if re.match(r"^#{1,6}\s", stripped):
151
+ return True
152
+ # Has multiple markdown headings
153
+ if len(re.findall(r"^#{1,6}\s", stripped, re.MULTILINE)) >= 2:
154
+ return True
155
+ # Has markdown link references [text][ref]
156
+ if re.search(r"\[.+\]\[.+\]", stripped):
157
+ return True
158
+ # Has markdown code fences
159
+ if "```" in stripped:
160
+ return True
161
+ # Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
162
+ return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
163
+
164
+
165
+def _rewrite_fossil_links(html: str, project_slug: str) -> str:
166
+ """Rewrite internal Fossil URLs to our app's URL structure.
167
+
168
+ Fossil links like /doc/trunk/www/file.wiki, /info/HASH, /wiki/PageName,
169
+ /tktview/HASH get mapped to our fossil app URLs.
170
+ """
171
+ if not project_slug:
172
+ return html
173
+
174
+ base = f"/projects/{project_slug}/fossil"
175
+
176
+ def replace_link(match):
177
+ url = match.group(1)
178
+ # /info/HASH -> checkin detail
179
+ m = re.match(r"/info/([0-9a-f]+)", url)
180
+ if m:
181
+ return f'href="{base}/checkin/{m.group(1)}/"'
182
+ # /doc/trunk/www/file or /doc/tip/... -> code file view
183
+ m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", url)
184
+ if m:
185
+ return f'href="{base}/code/file/{m.group(1)}"'
186
+ # /wiki?name=PageName -> wiki page (query string format)
187
+ m = re.match(r"/wiki\?name=(.+)", url)
188
+ if m:
189
+ return f'href="{base}/wiki/page/{m.group(1)}"'
190
+ # /wiki/PageName -> wiki page (path format)
191
+ m = re.match(r"/wiki/(.+)", url)
192
+ if m:
193
+ return f'href="{base}/wiki/page/{m.group(1)}"'
194
+ # /tktview/HASH or /tktview?name=HASH -> ticket detail
195
+ m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
196
+ if m:
197
+ return f'href="{base}/tickets/{m.group(1)}/"'
198
+ # /vdiff?from=X&to=Y -> compare view
199
+ m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
200
+ if m:
201
+ return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
202
+ # /timeline -> timeline
203
+ if url.startswith("/timeline"):
204
+ return f'href="{base}/timeline/"'
205
+ # /forumpost/HASH -> forum thread
206
+ m = re.match(r"/forumpost/([0-9a-f]+)", url)
207
+ if m:
208
+ return f'href="{base}/forum/{m.group(1)}/"'
209
+ # /forum -> forum list
210
+ if url.startswith("/forum"):
211
+ return f'href="{base}/forum/"'
212
+ # /www/file.wiki or /www/subdir/file -> doc page viewer
213
+ m = re.match(r"/(www/.+)", url)
214
+ if m:
215
+ return f'href="{base}/docs/{m.group(1)}"'
216
+ # /help/command -> Fossil help (link to fossil docs)
217
+ m = re.match(r"/help/(.+)", url)
218
+ if m:
219
+ return f'href="{base}/docs/www/help.wiki"'
220
+ # Bare .wiki or .md file paths (from relative link resolution)
221
+ m = re.match(r"/([^/]+\.(?:wiki|md|html))", url)
222
+ if m:
223
+ return f'href="{base}/docs/www/{m.group(1)}"'
224
+ # /dir -> our code browser
225
+ if url == "/dir" or url.startswith("/dir?"):
226
+ return f'href="{base}/code/"'
227
+ # /builtin/path -> code file (these are embedded skin files)
228
+ m = re.match(r"/builtin/(.+)", url)
229
+ if m:
230
+ return f'href="{base}/code/file/skins/{m.group(1)}"'
231
+ # /setup_*, /admin_* -> Fossil server routes, no mapping
232
+ if re.match(r"/(setup_|admin_)", url):
233
+ return match.group(0)
234
+ # Keep external and unrecognized links as-is
235
+ Alsoto our local forumths (from relative link resorumfor resolvim relative link resolutioforumpost/([0-9a-f]+)", base}/docs/www/{m.group(1)}forum/{m.group(1)}/"'
236
+ result.appenforum/"'
237
+return f'href="{base}/docs/etup_|admin_)", url):
238
+ rforumKeep external and unrecognized rum, html)((webhookwebhookwebhookwebhookrequest paramsstatus_paramtype}", type_param)url=secret=secref"Webhook for {url} creml = (html><head><title>{project.
239
+heckinport mark_safe
240
+from django.views.decorators.csrf import csrent)
241
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
242
+ html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
243
+
244
+ # Post-process: render pikchr fenced code blocks to_md(m):
245
+ P.PROJECT_VIEW.check(request.user)t math
246
+th
247
+import re
248
+from datetime www/") for resolving relativnder
249
+)
250
+ - Fossil-spimport contextlib
251
+import re
252
+
253
+import markdown as md
254
+from django.contrib.auth.decorators import login_required
255
+from django.404
256
+ts import get_object_or_404, redirect, render
257
+from django.utils.s
258
+from projects.models import Project
259
+
260
+from .models import FossilRepository
261
+from .reader import FossilReader
262
+
263
+
264
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
265
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
266
+
267
+ Fossil wiki pages can contain:
268
+ - Raw HTML (most Fossil wiki pages)
269
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
270
+ - Markdown (newer pages)
271
+
272
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
273
+ """
274
+ if not content:
275
+ return ""
276
+
277
+ # Detect format from the raw content BEFORE any transformations
278
+ is_markdown = _is_markdown(content)
279
+
280
+ if is_markdown:
281
+ # Markdown: convert Fossil [path | text] links to markdown links first
282
+ def _fossil_to_md_link(m):
283
+ path = m.group(1).strip()
284
+ text = m.group(2).strip()
285
+ if path.startswith("./"):
286
+ path = "/" + base_path + path[2:]
287
+ elif not path.startswith("/") and not path.startswith("http"):
288
+ path = "/" + base_path + path
289
+ return f"[{text}]({path})"
290
+
291
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
292
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
293
+ html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
294
+
295
+ # Post-process: render pikchr fenced code blocks to SVG
296
+ def _render_pikchr_md(m):
297
+ try:
298
+ from fossil.cli import FossilCLI
299
+
300
+ cli = FossilCLI()
301
+ svg = cli.render_pikchr(m.group(1))
302
+ if svg:
303
+ return f'<div class="pikchr-diagram">{svg}</div>'
304
+ except Exception:
305
+ .utils.s
306
+fromnder
307
+)
308
+ - Fossil-spimport contet math
309
+th
310
+import re
311
+from datetime www/") for resolving relativnder
312
+)
313
+ - Fossil-spimport contextlib
314
+import re
315
+
316
+import markdown as md
317
+from django.contrib.auth.decorators import login_required text = match.group(2).strip()
318
+ # Convert relative paths to absolute using base_path
319
+ if path.startswith("./"):
320
+ path = "/" + base_path + path[2:]
321
+ elif not path.startswith("/") and not path.startswith("http"):
322
+ path = "/" + base_path + path
323
+ return f'<a href="{path}">{text}</a>'
324
+
325
+ # Match [path | text] with flexible whitespace around the pipe
326
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
327
+ # Interwiki links: [wikipedia:Article] -> external link
328
+ content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
329
+ # Anchor links: [#anchor-name] -> local anchor
330
+ content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
331
+ # Bare wiki links: [PageName] (no pipe, not a URL)
332
+ content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
333
+
334
+ # Verbatim blocks
335
+ # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
336
+ def _render_pikchr_block(m):
337
+ try:
338
+ from fossil.cli import FossilCLI
339
+
340
+ cli = FossilCLI()
341
+ svg = cli.render_pikchr(m.group(1))
342
+ if svg:
343
+ return f'<div class="pikchr-diagram">{svg}</div>'
344
+ except Exception:
345
+ pass
346
+ return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
347
+
348
+ content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
349
+ # Regular verbatim blocks
350
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
351
+ # <nowiki> blocks — strip the tags, content passes through as-is
352
+ content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
353
+
354
+ # Convert Fossil wiki list syntax: * bullets and # enumeration
355
+ lines = content.split("\n")
356
+ result = []
357
+ in_list = False
358
+ list_type = "ul"
359
+ for line in lines:
360
+ stripped_line = line.strip()
361
+ is_bullet = re.match(r"^\*\s", stripped_line)
362
+ is_enum = re.match(r"^#\s", stripped_line) or re.match(�� it's Fossil wiki/HTP.PROJECT_VIEW.check(request.user)t math
363
+th
364
+import re
365
+from datetime www/") for resolving relativnder
366
+)
367
+ - Fossil-spimport contextlib
368
+import re
369
+
370
+import markdown as md
371
+from django.contrib.auth.decorators import login_required
372
+from django.404
373
+ts import get_object_or_404, redirect, render
374
+from django.utils.s
375
+from projects.models import Project
376
+
377
+from .models import FossilRepository
378
+from .reader import FossilReader
379
+
380
+
381
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
382
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
383
+
384
+ Fossil wiki pages can contain:
385
+ - Raw HTML (most Fossil wiki pages)
386
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
387
+ - Markdown (newer pages)
388
+
389
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
390
+ """
391
+ if not content:
392
+ return ""
393
+
394
+ # Detect format from the raw content BEFORE any transformations
395
+ is_markdown = _is_markdown(content)
396
+
397
+ if is_markdown:
398
+ # Markdown: convert Fossil [path | text] links to markdown links first
399
+ def _fossil_to_md_link(m):
400
+ path = m.group(1).strip()
401
+ text = m.group(2).strip()
402
+ if path.startswith("./"):
403
+ path = "/" + base_path + path[2:]
404
+ elif not path.startswith("/") and not path.startswith("http"):
405
+ path = "/" + base_path + path
406
+ return f"[{text}]({path})"
407
+
408
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
409
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
410
+ html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
411
+
412
+ # Post-process: render pikchr fenced code blocks to SVG
413
+ def _render_pikchr_md(m):
414
+ try:
415
+ from fossil.cli import FossilCLI
416
+
417
+ cli = FossilCLI()
418
+ svg = cli.render_pikchr(m.group(1))
419
+ if svg:
420
+ timelinecontextlib
421
+import matP.PROJECT_VIEW.check(request.user)t math
422
+th
423
+import re
424
+from datetime django.contrib.auth.decorators import login_rimport math
425
+[25, 50, 100]t re
426
+from datetime tlib
427
+import math
428
+import re
429
+from datetime import datetime
430
+
431
+import markdown as md
432
+from django.contrib.auth.decorators import login_required
433
+from django.core.paginator import Paginator
434
+from django.http import Http404, HttpResponse, JsonResponse
435
+from django.shortcuts import get_object_or_404, redirect, render
436
+from django.utils.safestring import mark_safe
437
+from django.views.decorators.csrf import csrf_exempt
438
+
439
+from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
440
+from core.sanitize import sanitize_html
441
+from proj,
442
+ }
443
+ contextlib
444
+imporib
445
+import math
446
+import re
447
+from datetwikipages": contextlib
448
+import math
449
+itextlib
450
+import mathportforumposts": post# Render each post's body tl
451
+from projects.models import import contextlib
452
+impoiki CRUDiticketP.PROJECT_VIEW.check(request.user)t math
453
+th
454
+import re
455
+from datetime actionontextlib
456
+import mport re
457
+from datetschedugit_ GitMirrordelete":
458
+ mirror_mirror_id")
459
+ mirror).firstmiirrorGit mirror removed."nort math
460
+import reb
461
+import mnotes": notewiki"-"):
462
+ mfile_diffs.append({"name": fname,}tags": tagcode"Raw File Downloadextlib
463
+import math
464
+import rport re
465
+from datetime import datetime
466
+
467
+import markdown as md
468
+from django.contrib.auth.decorators import login_required
469
+from django.core.paginator import Paginator
470
+from django.http import Http404, HttpResponse, JsonResponse
471
+from django.shortcuts import get_object_or_404, redirect, render
472
+from django.utils.safestring import mark_safe
473
+from django.views.decorators.csrf import csrf_exempt
474
+
475
+from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
476
+from core.sanitize import sanitize_html
477
+from projects.models import Project
478
+
479
+from .models import FossilRepository
480
+from .reader import FossilReader
481
+
482
+
483
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
484
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
485
+
486
+ Fossil wiki pages can contain:
487
+ - Raw HTML (most Fossil wiki pages)
488
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
489
+ - Markdown (newer pages)
490
+
491
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
492
+ """
493
+ if not content:
494
+ return ""
495
+
496
+ # Det
497
+imporib
498
+import mathP.PROJECT_VIEW.check(request.user)t math
499
+th
500
+import re
501
+from datetime www/") for resolving relativender
502
+)
503
+ - Fossil-spimport contextlib
504
+import re
505
+
506
+import markdown as md
507
+from django.contrib.auth.decorators import login_required
508
+from django.404
509
+ts import get_object_or_404, redirect, render
510
+from django.utils.s
511
+from projects.models import Project
512
+
513
+from .models import FossilRepository
514
+from .reader import FossilReader
515
+
516
+
517
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
518
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
519
+
520
+ Fossil wiki pages can contain:
521
+ - Raw HTML (most Fossil wiki pages)
522
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
523
+ - Markdown (newer pages)
524
+
525
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
526
+ """
527
+ if not content:
528
+ return ""
529
+
530
+ # Detect format from the raw content BEFORE any transformations
531
+ is_markdown = _is_markdown(content)
532
+
533
+ if is_markdnder
534
+)
535
+ - Fossil-spimpP.PROJECT_VIEW.check(request.user)t math
536
+th
537
+import re
538
+from datetime www/") for resolving relativndepage_name):
539
+ P.PROJEt math
540
+th
541
+import re
542
+from datetime www/") for resolving relativnder str = "") -> str:
543
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
544
+
545
+ Fossil wiki pages can contain:
546
+ - Raw HTML (most Fossil wiki pages)
547
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
548
+ - Markdown (newer pages)
549
+
550
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
551
+ """
552
+ if not content:
553
+ return ""
554
+
555
+ # Detect format from the raw content BEFORE any transformations
556
+ is_markdown = _is_markdown(content)
557
+
558
+ if is_markdown:
559
+ # Markdown: convert Fossil [path | text] links to markdown links first
560
+ def _fossil_to_md_link(m):
561
+ path = m.group(1).strip()
562
+ text = m.group(2).strip()
563
+ if path.startswith("./"):
564
+ path = "/" + base_path + path[2:]
565
+ elif not path.startswith("/") and not path.startswith("http"):
566
+ path = "/" + base_path + path
567
+ return f"[{text}]({path})"
568
+
569
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
570
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
571
+ html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
572
+
573
+ # Post-process: render pikchr fenced code blocks to SVG
574
+ def _render_pikchr_md(m):
575
+ try:
576
+ from fossil.cli import FossilCLI
577
+
578
+ cli = FossilCLI()
579
+ svg = cli.render_pikchr(m.group(1))
580
+ if svg:
581
+ return f'<div class="pikchr-diagram">{svg}</div>'
582
+ except Exception:
583
+ pass
584
+ return m.group(0)
585
+
586
+ html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
587
+ return _rewrite_fossil_links(html, project_slug) if project_slug else html
588
+
589
+ # Fossil wiki / HTML: convert Fossil-specific syntax to HTML
590
+ # Fossil links: [path | text] or [path|text] — spaces around pipe are optional
591
+ def _fossil_link_replace(match):
592
+ path = match.group(1).strip()
593
+ text = match.group(2).strip()
594
+ # Convert relative paths to absolute using base_path
595
+ if path.startswith("./"):
596
+ path = "/" + base_path + path[2:]
597
+ elif not path.startswith("/") and not path.startswith("http"):
598
+ path = "/" + base_path + path
599
+ return f'<a href="{path}">{text}</a>'
600
+
601
+ # Match [path | text] with flexible whitespace around the pipe
602
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
603
+ # Interwiki links: [wikipedia:Article] -> external link
604
+ content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
605
+ # Anchor links: [#anchor-name] -> local anchor
606
+ content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
607
+ # Bare wiki links: [PageName] (no pipe, not a URL)
608
+ content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
609
+
610
+ # Verbatim blocks
611
+ # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
612
+ def _render_pikchr_block(m):
613
+ try:
614
+ from fossil.cli import FossilCLI
615
+
616
+ cli = FossilCLI()
617
+ svg = cli.render_pikchr(m.group(1))
618
+ if svg:
619
+ return f'<div class="pikchr-diagram">{svg}</div>'
620
+ except Exception:
621
+ pass
622
+ return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
623
+
624
+ content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
625
+ # Regular verbatim blocks
626
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
627
+ # <nowiki> blocks — strip the tags, content passes through as-is
628
+ content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
629
+
630
+ # Convert Fossil wiki list syntax: * bullets and # enumeration
631
+ lines = content.split("\n")
632
+ result = []
633
+ in_list = False
634
+ list_type = "ul"
635
+ for line in lines:
636
+ stripped_line = line.strip()
637
+ is_bullet = re.match(r"^\*\s", stripped_line)
638
+ is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
639
+ if is_bullet or is_enum:
640
+ new_type = "ol" if is_enum else "ul"
641
+ if not in_list:
642
+ list_type = new_type
643
+ result.append(f"<{list_type}>")
644
+ in_list = True
645
+ elif new_type != list_type:
646
+ result.append(f"</{list_type}>")
647
+ list_type = new_type
648
+ result.append(f"<{list_type}>")
649
+ item_text = re.sub(r"^[\*#\d+\.\)]\s*", "", stripped_line)
650
+ result.append(f"<li>{item_text}</li>")
651
+ else:
652
+ if in_list:
653
+ result.append(f"</{list_type}>")
654
+ in_list = False
655
+ result.append(line)
656
+ if in_list:
657
+ result.append(f"</{list_type}>")
658
+
659
+ content = "\n".join(result)
660
+
661
+ # Wrap bare text blocks in <p> tags (lines not inside HTML tags)
662
+ content = re.sub(r"\n\n(?!<)", "\n\n<p>", content)
663
+
664
+ return _rewrite_fossil_links(content, project_slug) if project_slug else content
665
+
666
+
667
+def _is_markdown(content: str) -> bool:
668
+ """Detect if content is Markdown vs Fossil wiki/HTML.
669
+
670
+ Heuristic: if the content starts with markdown-style headings (#),
671
+ or has significant markdown syntax patterns, treat as markdown.
672
+ """
673
+ stripped = content.strip()
674
+ # Starts with markdown heading
675
+ if re.match(r"^#{1,6}\s", stripped):
676
+ return True
677
+ # Has multiple markdown headings
678
+ if len(re.findall(r"^#{1,6}\s", stripped, re.MULTILINE)) >= 2:
679
+ return True
680
+ # Has markdown link references [text][ref]
681
+ if re.search(r"\[.+\]\[.+\]", stripped):
682
+ return True
683
+ # Has markdown code fences
684
+ if "```" in stripped:
685
+ return True
686
+ # Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
687
+ return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
688
+
689
+
690
+def _rewrite_fossil_links(html: str, project_slug: str) -> str:
691
+ """Rewrite internal Fossil URLs to our app's URL structure.
692
+
693
+ Fossil links like /doc/trunk/www/file.wiki, /info/HASH, /wiki/PageName,
694
+ /tktview/HASH get mapped to our fossil app URLs.
695
+ """
696
+ if not project_slug:
697
+ return html
698
+
699
+ base = f"/projects/{project_slug}/fossil"
700
+
701
+ def replace_link(match):
702
+ url = match.group(1)
703
+ # /info/HASH -> checkin detail
704
+ m = re.match(r"/info/([0-9a-f]+)", url)
705
+ if m:
706
+ return f'href="{base}/checkin/{m.group(1)}/"'
707
+ # /doc/trunk/www/file or /doc/tip/... -> code file view
708
+ m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", url)
709
+ if m:
710
+ return f'href="{base}/code/file/{m.group(1)}"'
711
+ # /wiki?name=PageName -> wiki page (query string format)
712
+ m = re.match(r"/wiki\?name=(.+)", url)
713
+ if m:
714
+ return f'href="{base}/wiki/page/{m.group(1)}"'
715
+ # /wiki/PageName -> wiki page (path format)
716
+ m = re.match(r"/wiki/(.+)", url)
717
+ if m:
718
+ return f'href="{base}/wiki/page/{m.group(1)}"'
719
+ # /tktview/HASH or /tktview?name=HASH -> ticket detail
720
+ m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
721
+ if m:
722
+ return f'href="{base}/tickets/{m.group(1)}/"'
723
+ # /vdiff?from=X&to=Y -> compare view
724
+ m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
725
+ if m:
726
+ return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
727
+ # /timeline -> timeline
728
+ if url.startswith("/timeline"):
729
+ return f'href="{base}/timeline/"'
730
+ # /forumpost/HASH -> forum thread
731
+ m = re.match(r"/forumpost/([0-9a-f]+)", url)
732
+ if m:
733
+ return f'href="{base}/forum/{m.group(1)}/"'
734
+ # /forum -> forum list
735
+ if url.startswith("/forum"):
736
+ return f'href="{base}/forum/"'
737
+ # /www/file.wiki or /www/subdir/file -> doc page viewer
738
+ m = re.match(r"/(www/.+)", url)
739
+ if m:
740
+ return f'href="{base}/docs/{m.group(1)}"'
741
+ # /help/command -> Fossil help (link to fossil docs)
742
+ m = re.match(r"/help/(.+)", url)
743
+ if m:
744
+ return f'href="{base}/docs/www/help.wiki"'
745
+ # Bare .wiki or .md file paths (from relative link resolution)
746
+ m = re.match(r"/([^/]+\.(?:wiki|md|html))", url)
747
+ if m:
748
+ return f'href="{base}/docs/www/{m.group(1)}"'
749
+ # /dir -> our code browser
750
+ if url == "/dir" or url.startswith("/dir?"):
751
+ return f'href="{base}/code/"'
752
+ # /builtin/path -> code file (these are embedded skin files)
753
+ m = re.match(r"/builtin/(.+)", url)
754
+ if m:
755
+ return f'href="{base}/code/file/skins/{m.group(1)}"'
756
+ # /setup_*, /admin_* -> Fossil server routes, no mapping
757
+ if re.match(r"/(setup_|admin_)", url):
758
+ return match.group(0)
759
+ # Keep external and unrecognized links as-is
760
+ Alsoto our local forumths (from relative link resorumfor resolvim relative link resolutioforumpost/([0-9a-f]+)", base}/docs/www/{m.group(1)}forum/{m.group(1)}/"'
761
+ result.appenforum/"'
762
+return f'href="{base}/docs/etup_|admin_)", url):
763
+ rforumKeep external and unrecognized rum, html)((webhookwebhookwebhookwebhookrequest paramsstatus_paramtype}", type_param)url=secret=secref"Webhook for {url} crea# Only update secret (don't blank it on ediwebhookupdahtml = (html><head><title>{project.project.project.clone_url} {project.b
764
+import math
765
+import re
766
+from datetime import datetime
767
+
768
+import markdown as md
769
+from djangotlib.")readme_code_fil":diff_lines.append({"text": line,t contextlib
770
+importmport re
771
+from dateti})
772
+heckinport mark_safe
773
+from django.views.decorators.csrf import csrf_exempt
774
+
775
+from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
776
+from core.sanitize import sanitize_html
777
+from projects.models import Projecimint(per_page", "50"))per_page if per_pa contextlib
778
+import math
779
+import re
780
+from datetime import datetime
781
+
782
+import markdown as md
783
+from django.contrib.auth.decorators import login_required
784
+from django.core.paginator import Paginator
785
+from django.http import Http404, HttpResponse, JsonResponse
786
+from django.shortcuts import get_object_or_404, redirect, render
787
+from django.utils.safestring import mark_safe
788
+from django.views.decorators.csrf import csrf_exempt
789
+
790
+from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
791
+from core.sanitize import sanitize_html
792
+from projects.models import Project
793
+
794
+from .models import FossilRepository
795
+from .reader import FossilReader
796
+
797
+
798
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
799
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
800
+
801
+ Fossil wiki pages can contain:
802
+ - Raw HTML (most Fossil wiki pages)
803
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
804
+ - Markdown (newer pages)
805
+
806
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
807
+ """
808
+ if not content:
809
+ return ""
810
+
811
+ # Detect format from the raw content BEFORE any transformations
812
+ is_markdown = _is_markdown(content)
813
+
814
+ if is_markdown:
815
+ # Markdown: convert Fossil [path | text] links to markdown links first
816
+ def _fossil_to_md_link(m):
817
+ path = m.group(1).strip()
818
+ text = m.group(2).strip()
819
+ if path.startswith("./"):
820
+ path = "/" + base_path + path[2:]
821
+ elif not path.startswith("/") and not path.startswith("http"):
822
+ path = "/" + base_path + path
823
+ return f"[{text}]({path})"
824
+
825
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
826
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
827
+ html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
828
+
829
+ # Post-process: render pikchr fenced code blocks to SVG
830
+ def _render_pikchr_md(m):
831
+ try:
832
+ from fossil.cli import FossilCLI
833
+
834
+ cli = FossilCLI()
835
+ svg = cli.render_pikchr(m.group(1))
836
+ if svg:
837
+ return f'<div class="pikchr-diagram">{svg}</div>'
838
+ except Exception:
839
+ pass
840
+ return m.group(0)
841
+
842
+ html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
843
+ return _rewrite_fossil_links(html, project_slug) if project_slug else html
844
+
845
+ # Fossil wiki / HTML: convert Fossil-specific syntax to HTML
846
+ # Fossil links: [path | text] or [path|text] — spaces around pipe are optional
847
+ def _fossil_link_replace(match):
848
+ path = match.group(1).strip()
849
+ text = match.group(2).strip()
850
+ # Convert relative paths to absolute using base_path
851
+ if path.startswith("./"):
852
+ path = "/" + base_path + path[2:]
853
+ elif not path.startswith("/") and not path.startswith("http"):
854
+ path = "/" + base_path + path
855
+ return f'<a href="{path}">{text}</a>'
856
+
857
+ # Match [path | text] with flexible whitespace around the pipe
858
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
859
+ # Interwiki links: [wikipedia:Article] -> external link
860
+ content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
861
+ # Anchor links: [#anchor-name] -> local anchor
862
+ content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
863
+ # Bare wiki links: [PageName] (no pipe, not a URL)
864
+ content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
865
+
866
+ # Verbatim blocks
867
+ # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
868
+ def _render_pikchr_block(m):
869
+ try:
870
+ from fossil.cli import FossilCLI
871
+
872
+ cli = FossilCLI()
873
+ svg = cli.render_pikchr(m.group(1))
874
+ if svg:
875
+ return f'<div class="pikchr-diagram">{svg}</div>'
876
+ except Exception:
877
+ pass
878
+ return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
879
+
880
+ content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
881
+ # Regular verbatim blocks
882
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
883
+ # <nowiki> blocks — strip the tags, content passes through as-is
884
+ content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
885
+
886
+ # Convert Fossil wiki list syntax: * bullets and # enumeration
887
+ lines = content.split("\n")
888
+ result = []
889
+ in_list = False
890
+ list_type = "ul"
891
+ for line in lines:
892
+ stripped_line = line.strip()
893
+ is_bullet = re.match(r"^\*\s", stripped_line)
894
+ is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
895
+ if is_bullet or is_enum:
896
+ new_type = "ol" if is_enum else "ul"
897
+ if not in_list:
898
+ list_type = new_type
899
+ result.append(f"<{list_type}>")
900
+ in_list = True
901
+ elif new_type != list_type:
902
+ result.append(f"</{list_type}>")
903
+ list_type = new_type
904
+ result.append(f"<{list_type}>")
905
+ item_text = re.sub(r"^[\*#\d+\.\)]\s*", "", stripped_line)
906
+ result.append(f"<li>{item_text}</li>")
907
+ else:
908
+ if in_list:
909
+ result.append(f"</{list_type}>")
910
+ in_list = False
911
+ result.append(line)
912
+ if in_list:
913
+ result.append(f"</{list_type}>")
914
+
915
+ content = "\n".join(result)
916
+
917
+ # Wrap bare text blocks in <p> tags (lines not inside HTML tags)
918
+ content = re.sub(r"\n\n(?!<)", "\n\n<p>", content)
919
+
920
+ return _rewrite_fossil_links(content, project_slug) if project_slug else content
921
+
922
+
923
+def _is_markdown(content: str) -> bool:
924
+ """Detect if content is Markdown vs Fossil wiki/HTML.
925
+
926
+ Heuristic: if the content starts with markdown-style headings (#),
927
+ or has significant markdown syntax patterns, treat as markdown.
928
+ """
929
+ stripped = content.strip()
930
+ # Starts with markdown heading
931
+ if re.match(r"^#{1,6}\s", stripped):
932
+ return True
933
+ # Has multiple markdown headings
934
+ if len(re.findall(r"^#{1,6}\s", stripped, re.MULTILINE)) >= 2:
935
+ return True
936
+ # Has markdown link references [text][ref]
937
+ if re.search(r"\[.+\]\[.+\]", stripped):
938
+ return True
939
+ # Has markdown code fences
940
+ if "```" in stripped:
941
+ return True
942
+ # Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
943
+ return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
944
+
945
+
946
+def _rewrite_fossil_links(html: str, project_slug: str) -> str:
947
+ """Rewrite internal Fossil URLs to our app's URL structure.
948
+
949
+ Fossil links like /doc/trunk/www/file.wiki, /info/HASH, /wiki/PageName,
950
+ /tktview/HASH get mapped to our fossil app URLs.
951
+ """
952
+ if not project_slug:
953
+ return html
954
+
955
+ base = f"/projects/{project_slug}/fossil"
956
+
957
+ def replace_link(match):
958
+ url = match.group(1)
959
+ # /info/HASH -> checkin detail
960
+ m = re.match(r"/info/([0-9a-f]+)", url)
961
+ if m:
962
+ return f'href="{base}/checkin/{m.group(1)}/"'
963
+ # /doc/trunk/www/file or /doc/tip/... -> code file view
964
+ m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", url)
965
+ if m:
966
+ return f'href="{base}/code/file/{m.group(1)}"'
967
+ # /wiki?name=PageName -> wiki page (query string format)
968
+ m = re.match(r"/wiki\?name=(.+)", url)
969
+ if m:
970
+ return f'href="{base}/wiki/page/{m.group(1)}"'
971
+ # /wiki/PageName -> wiki page (path format)
972
+ m = re.match(r"/wiki/(.+)", url)
973
+ if m:
974
+ return f'href="{base}/wiki/page/{m.group(1)}"'
975
+ # /tktview/HASH or /tktview?name=HASH -> ticket detail
976
+ m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
977
+ if m:
978
+ return f'href="{base}/tickets/{m.group(1)}/"'
979
+ # /vdiff?from=X&to=Y -> compare view
980
+ m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
981
+ if m:
982
+ return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
983
+ # /timeline -> timeline
984
+ if url.startswith("/timeline"):
985
+ return f'href="{base}/timeline/"'
986
+ # /forumpost/HASH -> forum thread
987
+ m = re.match(r"/forumpost/([0-9a-f]+)", url)
988
+ if m:
989
+ return f'href="{base}/forum/{m.group(1)}/"'
990
+ # /forum -> forum list
991
+ if url.startswith("/forum"):
992
+ return f'href="{base}/forum/"'
993
+ # /www/file.wiki or /www/subdir/file -> doc page viewer
994
+ m = re.match(r"/(www/.+)", url)
995
+ if m:
996
+ return f'href="{base}/docs/{m.group(1)}"'
997
+ # /help/command -> Fossil help (link to fossil docs)
998
+ m = re.match(r"/help/(.+)", url)
999
+ if m:
1000
+ return f'href="{base}/docs/www/help.wiki"'
1001
+ # Bare .wiki or .md file paths (from relative link resolution)
1002
+ m = re.match(r"/([^/]+\.(?:wiki|md|html))", url)
1003
+ if m:
1004
+ return f'href="{base}/docs/www/{m.group(1)}"'
1005
+ # /dir -> our code browser
1006
+ if url == "/dir" or url.startswith("/dir?"):
1007
+ return f'href="{base}/code/"'
1008
+ # /builtin/path -> code file (these are embedded skin files)
1009
+ m = re.match(r"/builtin/(.+)", url)
1010
+ if m:
1011
+ return f'href="{base}/code/file/skins/{m.group(1)}"'
1012
+ # /setup_*, /admin_* -> Fossil server routes, no mapping
1013
+ if re.match(r"/(setup_|admin_)", url):
1014
+ return match.group(0)
1015
+ # Keep external and unrecognized links as-is
1016
+ Alsoto our local forumths (from relative link resorumfor resolvim relative link resolutioforumpost/([0-9a-f]+)", base}/docs/www/{m.group(1)}forum/{m.group(1)}/"'
1017
+ result.appenforum/"'
1018
+return f'href="{base}/docs/etup_|admin_)", url):
1019
+ rforumKeep external and unrecognized rum, html)((webhookwebhookwebhookwebhookrequest paramsstatus_paramtype}", type_param)url=secret=secref"Webhook for {url} crea# Only update secret (don't blank it on ediwebhookupdahtml = (html><head><title>{project.project.project.clone_url} {project.b
1020
+import math
1021
+import re
1022
+from datetime import datetime
1023
+
1024
+import markdown as md
1025
+from djangotlib.")import contextlibimport contextlib
1026
+import math
1027
+import re
1028
+from datetime import datetime
1029
+
1030
+import markdown as md
1031
+from django.contrib.auth.decorators import login_rimport math
1032
+[25, 50, 100]t re
1033
+from datetime tlib
1034
+import math
1035
+import re
1036
+from datetime import datetime
1037
+
1038
+import markdown as md
1039
+from django.contrib.auth.decorators import login_required
1040
+from django.core.paginator import Paginator
1041
+from django.http import Http404, HttpResponse, JsonResponse
1042
+from django.shortcuts import get_object_or_404, redirect, render
1043
+from django.utils.safestring import mark_safe
1044
+from django.views.decorators.csrf import csrf_exempt
1045
+
1046
+from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
1047
+from core.sanitize import sanitize_html
1048
+from proj,
1049
+ }
1050
+ contextlib
1051
+imporib
1052
+import math
1053
+import re
1054
+from datetwikipages": contextlib
1055
+import math
1056
+itextlib
1057
+import mathportforumposts": post# Render each post's body tl
1058
+from projects.models import import contextlib
1059
+impoiki CRUDikirequest,}import contextlib
1060
+importimport contextlib
1061
+import math
1062
+th
1063
+import re
1064
+from datetime import daactionontextlib
1065
+import mport re
1066
+from datetschedugit_ GitMirrordelete":
1067
+ mirror_mirror_id")
1068
+ mirror).firstmiirrorGit mirror removed."nort math
1069
+import reb
1070
+import mnotes": notewiki"-"):
1071
+ mfile_diffs.append({"name": fname,}tags": tagcode"Raw File Downloadextlib
1072
+import math
1073
+import rport re
1074
+from datetime import datetime
1075
+
1076
+import markdown as md
1077
+from django.contrib.auth.decorators import login_required
1078
+from django.core.paginator import Paginator
1079
+from django.http import Http404, HttpResponse, JsonResponse
1080
+from django.shortcuts import get_object_or_404, redirect, render
1081
+from django.utils.safestring import mark_safe
1082
+from django.views.decorators.csrf import csrf_exempt
1083
+
1084
+from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
1085
+from core.sanitize import sanitize_html
1086
+from projects.models import Project
1087
+
1088
+from .models import FossilRepository
1089
+from .reader import FossilReader
1090
+
1091
+
1092
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
1093
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
1094
+
1095
+ Fossil wiki pages can contain:
1096
+ - Raw HTML (most Fossil wiki pages)
1097
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
1098
+ - Markdown (newer pages)
1099
+
1100
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
1101
+ """
1102
+ if not content:
1103
+ return ""
1104
+
1105
+ # Detect docdef
1106
+ """ = {}
1107
+ rid_to_railFor each row, compute:
1108
+ # 1. Which vertical rails are active (have a line passing through)
1109
+ # 2. Whether there's a fork/merge connector to drawto its parconnectors: for each row, collect all horizontal connections
1110
+ # A connector appears when a child on one rail connects to a parent
1111
+ # We draw the connector at BOTH the child row (fork out) and on every row where
1112
+ # a branch line needs to cross from one rail to anotherfor entryto markdown links firimport contextlib
1113
+import ma or entry.parent_rid not= "/" + base_path + patimport.get, 0)
1114
+:
1115
+ childchildparenparenconn = {"left": min(child_x, parent_x), "width": abs(child_x - parent_x)}
1116
+ # Draw at the parent's row (where branch meets trunk)
1117
+parent_idxresultcontextlib
1118
+imimport re
1119
+f}
1120
+sult
1121
+OAuthGit sync triggered. Chender
1122
+)
1123
+ - Fossil-spimport contextlib
1124
+import re
1125
+
1126
+import markdown as md
1127
+from django.contrib.auth.decorators import login_required
1128
+from django.404
1129
+ts import get_object_or_404, redirect, render
1130
+from django.utils.s
1131
+from projects.models import Project
1132
+
1133
+from .models import FossilRepository
1134
+from .reader import FossilReader
1135
+
1136
+
1137
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
1138
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
1139
+
1140
+ Fossil wiki pages can contain:
1141
+ - Raw HTML (most FforumP.PROJECT_VIEW.check(request.user)t math
1142
+th
1143
+import re
1144
+from datetime www/") for resolving relativnder
1145
+)
1146
+ - Fossil-spimpP.PROJECT_VIEW.check(request.user)t math
1147
+th
1148
+import re
1149
+from datetime www/") for resolving relativnder
1150
+)
1151
+ - Fossil-spimport contextlib
1152
+import re
1153
+
1154
+import markdown as md
1155
+from django.contrib.auth.decorators import login_required
1156
+from django.404
1157
+ts import get_object_or_404, redirect, render
1158
+from django.utils.s
1159
+from projects.models import Project
1160
+
1161
+from .models import FossilRepository
1162
+from .reader import FossilReader
1163
+
1164
+
1165
+def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
1166
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
1167
+
1168
+ Fossil wiki pages can contain:
1169
+ - Raw HTML (most Fossil wiki pages)
1170
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
1171
+ - Markdown (newer pages)
1172
+
1173
+ base_path: directory of the current file (e.g. "www/") for rt math
1174
+th
1175
+import re
1176
+from datetime t math
1177
+th
1178
+import re
1179
+from datetime www/") for resolving relativnder str = "") -> str:
1180
+ """Render content that may be Fossil wiki markup, HTML, or Markdown.
1181
+
1182
+ Fossil wiki pages can contain:
1183
+ - Raw HTML (most Fossil wiki pages)
1184
+ - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
1185
+ - Markdown (newer pages)
1186
+
1187
+ base_path: directory of the current file (e.g. "www/") for resolving relative links.
1188
+ """
1189
+ if not content:
1190
+ return ""
1191
+
1192
+ # Detect format from the raw content BEFORE any transformations
1193
+ is_markdown = _is_markdown(content)
1194
+
1195
+ if is_markdown:
1196
+ # Markdown: convert Fossil [path | text] links to markdown links first
1197
+ def _fossil_to_md_link(m):
1198
+ path = m.group(1).strip()
1199
+ text = m.group(2).strip()
1200
+ if path.startswith("./"):
1201
+ path = "/" + base_path + path[2:]
1202
+ elif not path.startswith("/") and not path.startswith("http"):
1203
+ path = "/" + base_path + path
1204
+ return f"[{text}]({path})"
1205
+
1206
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
1207
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
1208
+ html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
1209
+
1210
+ # Post-process: render pikchr fenced code blocks to SVG
1211
+ def _render_pikchr_md(m):
1212
+ try:
1213
+ from fossil.cli import FossilCLI
1214
+
1215
+ cli = FossilCLI()
1216
+ svg = cli.render_pikchr(m.group(1))
1217
+ if svg:
1218
+ return f'<div class="pikchr-diagram">{svg}</div>'
1219
+ except Exception:
1220
+ pass
1221
+ return m.group(0)
1222
+
1223
+ html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
1224
+ return _rewrite_fossil_links(html, project_slug) if project_slug else html
1225
+
1226
+ # Fossil wiki / HTML: convert Fossil-specific syntax to HTML
1227
+ # Fossil links: [path | text] or [path|text] — spaces around pipe are optional
1228
+ def _fossil_link_replace(match):
1229
+ path = match.group(1).strip()
1230
+ text = match.group(2).strip()
1231
+ # Convert relative paths to absolute using base_path
1232
+ if path.startswith("./"):
1233
+ path = "/" + base_path + path[2:]
1234
+ elif not path.startswith("/") and not path.startswith("http"):
1235
+ path = "/" + base_path + path
1236
+ return f'<a href="{path}">{text}</a>'
1237
+
1238
+ # Match [path | text] with flexible whitespace around the pipe
1239
+ content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
1240
+ # Interwiki links: [wikipedia:Article] -> external link
1241
+ content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
1242
+ # Anchor links: [#anchor-name] -> local anchor
1243
+ content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
1244
+ # Bare wiki links: [PageName] (no pipe, not a URL)
1245
+ content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
1246
+
1247
+ # Verbatim blocks
1248
+ # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
1249
+ def _render_pikchr_block(m):
1250
+ try:
1251
+ from fossil.cli import FossilCLI
1252
+
1253
+ cli = FossilCLI()
1254
+ svg = cli.render_pikchr(m.group(1))
1255
+ if svg:
1256
+ return f'<div class="pikchr-diagram">{svg}</div>'
1257
+ except Exception:
1258
+ pass
1259
+ return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
1260
+
1261
+ content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
1262
+ # Regular verbatim blocks
1263
+ content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
1264
+ # <nowiki> blocks — strip the tags, content passes through as-is
1265
+ content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
1266
+
1267
+ # Convert Fossil wiki list syntax: * bullets and # enumeration
1268
+ lines = content.split("\n")
1269
+ result = []
1270
+ in_list = False
1271
+ list_type = "ul"
1272
+ for line in lines:
1273
+ stripped_line = line.strip()
1274
+ is_bullet = re.match(r"^\*\s", stripped_line)
1275
+ is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
1276
+ if is_bullet or is_enum:
1277
+ new_type = "ol" if is_enum else "ul"
1278
+ if not in_list:
1279
+ list_type = new_type
1280
+ result.append(f"<{list_type}>")
1281
+ in_list = True
1282
+ elif new_type != list_
--- a/fossil/views.py
+++ b/fossil/views.py
@@ -0,0 +1,1282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/views.py
+++ b/fossil/views.py
@@ -0,0 +1,1282 @@
1 nder
2 )
3 - Fossil-spimport contextlib
4 import re
5
6 import markdown as md
7 from django.contrib.auth.decorators import login_required
8 from django.404
9 ts import get_object_or_404, redirect, render
10 from django.utils.s
11 from procore.permissions import Pom projects.models import Project
12
13 from .models import FossilRepository
14 from .reader import FossilReader
15
16
17 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
18 """Render content that may be Fossil wiki markup, HTML, or Markdown.
19
20 Fossil wiki pages can contain:
21 - Raw HTML (most Fossil wiki pages)
22 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
23 - Markdown (newer pages)
24
25 base_path: directory of the current file (e.g. "www/") for resolving relative links.
26 """
27 if not content:
28 return ""
29
30 # Detect format from the raw content BEFORE any transformations
31 is_markdown = _is_markdown(content)
32
33 if is_markdown:
34 # Markdown: convert Fossil [path | text] links to markdown links first
35 def _fossil_to_md_link(m):
36 path = m.group(1).strip()
37 text = m.group(2).strip()
38 if path.startswith("./"):
39 path = "/" + base_path + path[2:]
40 elif not path.startswith("/") and not path.startswith("http"):
41 path = "/" + base_path + path
42 return f"[{text}]({path})"
43
44 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
45 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
46 html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
47
48 # Post-process: render pikchr fenced code blocks to SVG
49 def _render_pikchr_md(m):
50 try:
51 from fossil.cli import FossilCLI
52
53 cli = FossilCLI()
54 svg = cli.render_pikchr(m.group(1))
55 if svg:
56 return f'<div class="pikchr-diagram">{svg}</div>'
57 except Exception:
58 pass
59 return m.group(0)
60
61 html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
62 return _rewrite_fossil_links(html, project_slug) if project_slug else html
63
64 # Fossil wiki / HTML: convert Fossil-specific syntax to HTML
65 # Fossil links: [path | text] or [path|text] — spaces around pipe are optional
66 def _fossil_link_replace(match):
67 path = match.group(1).strip()
68 text = match.group(2).strip()
69 # Convert relative paths to absolute using base_path
70 if path.startswith("./"):
71 path = "/" + base_path + path[2:]
72 elif not path.startswith("/") and not path.startswith("http"):
73 path = "/" + base_path + path
74 return f'<a href="{path}">{text}</a>'
75
76 # Match [path | text] with flexible whitespace around the pipe
77 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
78 # Interwiki links: [wikipedia:Article] -> external link
79 content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
80 # Anchor links: [#anchor-name] -> local anchor
81 content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
82 # Bare wiki links: [PageName] (no pipe, not a URL)
83 content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
84
85 # Verbatim blocks
86 # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
87 def _render_pikchr_block(m):
88 try:
89 from fossil.cli import FossilCLI
90
91 cli = FossilCLI()
92 svg = cli.render_pikchr(m.group(1))
93 if svg:
94 return f'<div class="pikchr-diagram">{svg}</div>'
95 except Exception:
96 pass
97 return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
98
99 content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
100 # Regular verbatim blocks
101 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
102 # <nowiki> blocks — strip the tags, content passes through as-is
103 content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
104
105 # Convert Fossil wiki list syntax: * bullets and # enumeration
106 lines = content.split("\n")
107 result = []
108 in_list = False
109 list_type = "ul"
110 for line in lines:
111 stripped_line = line.strip()
112 is_bullet = re.match(r"^\*\s", stripped_line)
113 is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
114 if is_bullet or is_enum:
115 new_type = "ol" if is_enum else "ul"
116 if not in_list:
117 list_type = new_type
118 result.append(f"<{list_type}>")
119 in_list = True
120 elif new_type != list_type:
121 result.append(f"</{list_type}>")
122 list_type = new_type
123 result.append(f"<{list_type}>")
124 item_text = re.sub(r"^[\*#\d+\.\)]\s*", "", stripped_line)
125 result.append(f"<li>{item_text}</li>")
126 else:
127 if in_list:
128 result.append(f"</{list_type}>")
129 in_list = False
130 result.append(line)
131 if in_list:
132 result.append(f"</{list_type}>")
133
134 content = "\n".join(result)
135
136 # Wrap bare text blocks in <p> tags (lines not inside HTML tags)
137 content = re.sub(r"\n\n(?!<)", "\n\n<p>", content)
138
139 return _rewrite_fossil_links(content, project_slug) if project_slug else content
140
141
142 def _is_markdown(content: str) -> bool:
143 """Detect if content is Markdown vs Fossil wiki/HTML.
144
145 Heuristic: if the content starts with markdown-style headings (#),
146 or has significant markdown syntax patterns, treat as markdown.
147 """
148 stripped = content.strip()
149 # Starts with markdown heading
150 if re.match(r"^#{1,6}\s", stripped):
151 return True
152 # Has multiple markdown headings
153 if len(re.findall(r"^#{1,6}\s", stripped, re.MULTILINE)) >= 2:
154 return True
155 # Has markdown link references [text][ref]
156 if re.search(r"\[.+\]\[.+\]", stripped):
157 return True
158 # Has markdown code fences
159 if "```" in stripped:
160 return True
161 # Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
162 return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
163
164
165 def _rewrite_fossil_links(html: str, project_slug: str) -> str:
166 """Rewrite internal Fossil URLs to our app's URL structure.
167
168 Fossil links like /doc/trunk/www/file.wiki, /info/HASH, /wiki/PageName,
169 /tktview/HASH get mapped to our fossil app URLs.
170 """
171 if not project_slug:
172 return html
173
174 base = f"/projects/{project_slug}/fossil"
175
176 def replace_link(match):
177 url = match.group(1)
178 # /info/HASH -> checkin detail
179 m = re.match(r"/info/([0-9a-f]+)", url)
180 if m:
181 return f'href="{base}/checkin/{m.group(1)}/"'
182 # /doc/trunk/www/file or /doc/tip/... -> code file view
183 m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", url)
184 if m:
185 return f'href="{base}/code/file/{m.group(1)}"'
186 # /wiki?name=PageName -> wiki page (query string format)
187 m = re.match(r"/wiki\?name=(.+)", url)
188 if m:
189 return f'href="{base}/wiki/page/{m.group(1)}"'
190 # /wiki/PageName -> wiki page (path format)
191 m = re.match(r"/wiki/(.+)", url)
192 if m:
193 return f'href="{base}/wiki/page/{m.group(1)}"'
194 # /tktview/HASH or /tktview?name=HASH -> ticket detail
195 m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
196 if m:
197 return f'href="{base}/tickets/{m.group(1)}/"'
198 # /vdiff?from=X&to=Y -> compare view
199 m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
200 if m:
201 return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
202 # /timeline -> timeline
203 if url.startswith("/timeline"):
204 return f'href="{base}/timeline/"'
205 # /forumpost/HASH -> forum thread
206 m = re.match(r"/forumpost/([0-9a-f]+)", url)
207 if m:
208 return f'href="{base}/forum/{m.group(1)}/"'
209 # /forum -> forum list
210 if url.startswith("/forum"):
211 return f'href="{base}/forum/"'
212 # /www/file.wiki or /www/subdir/file -> doc page viewer
213 m = re.match(r"/(www/.+)", url)
214 if m:
215 return f'href="{base}/docs/{m.group(1)}"'
216 # /help/command -> Fossil help (link to fossil docs)
217 m = re.match(r"/help/(.+)", url)
218 if m:
219 return f'href="{base}/docs/www/help.wiki"'
220 # Bare .wiki or .md file paths (from relative link resolution)
221 m = re.match(r"/([^/]+\.(?:wiki|md|html))", url)
222 if m:
223 return f'href="{base}/docs/www/{m.group(1)}"'
224 # /dir -> our code browser
225 if url == "/dir" or url.startswith("/dir?"):
226 return f'href="{base}/code/"'
227 # /builtin/path -> code file (these are embedded skin files)
228 m = re.match(r"/builtin/(.+)", url)
229 if m:
230 return f'href="{base}/code/file/skins/{m.group(1)}"'
231 # /setup_*, /admin_* -> Fossil server routes, no mapping
232 if re.match(r"/(setup_|admin_)", url):
233 return match.group(0)
234 # Keep external and unrecognized links as-is
235 Alsoto our local forumths (from relative link resorumfor resolvim relative link resolutioforumpost/([0-9a-f]+)", base}/docs/www/{m.group(1)}forum/{m.group(1)}/"'
236 result.appenforum/"'
237 return f'href="{base}/docs/etup_|admin_)", url):
238 rforumKeep external and unrecognized rum, html)((webhookwebhookwebhookwebhookrequest paramsstatus_paramtype}", type_param)url=secret=secref"Webhook for {url} creml = (html><head><title>{project.
239 heckinport mark_safe
240 from django.views.decorators.csrf import csrent)
241 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
242 html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
243
244 # Post-process: render pikchr fenced code blocks to_md(m):
245 P.PROJECT_VIEW.check(request.user)t math
246 th
247 import re
248 from datetime www/") for resolving relativnder
249 )
250 - Fossil-spimport contextlib
251 import re
252
253 import markdown as md
254 from django.contrib.auth.decorators import login_required
255 from django.404
256 ts import get_object_or_404, redirect, render
257 from django.utils.s
258 from projects.models import Project
259
260 from .models import FossilRepository
261 from .reader import FossilReader
262
263
264 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
265 """Render content that may be Fossil wiki markup, HTML, or Markdown.
266
267 Fossil wiki pages can contain:
268 - Raw HTML (most Fossil wiki pages)
269 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
270 - Markdown (newer pages)
271
272 base_path: directory of the current file (e.g. "www/") for resolving relative links.
273 """
274 if not content:
275 return ""
276
277 # Detect format from the raw content BEFORE any transformations
278 is_markdown = _is_markdown(content)
279
280 if is_markdown:
281 # Markdown: convert Fossil [path | text] links to markdown links first
282 def _fossil_to_md_link(m):
283 path = m.group(1).strip()
284 text = m.group(2).strip()
285 if path.startswith("./"):
286 path = "/" + base_path + path[2:]
287 elif not path.startswith("/") and not path.startswith("http"):
288 path = "/" + base_path + path
289 return f"[{text}]({path})"
290
291 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
292 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
293 html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
294
295 # Post-process: render pikchr fenced code blocks to SVG
296 def _render_pikchr_md(m):
297 try:
298 from fossil.cli import FossilCLI
299
300 cli = FossilCLI()
301 svg = cli.render_pikchr(m.group(1))
302 if svg:
303 return f'<div class="pikchr-diagram">{svg}</div>'
304 except Exception:
305 .utils.s
306 fromnder
307 )
308 - Fossil-spimport contet math
309 th
310 import re
311 from datetime www/") for resolving relativnder
312 )
313 - Fossil-spimport contextlib
314 import re
315
316 import markdown as md
317 from django.contrib.auth.decorators import login_required text = match.group(2).strip()
318 # Convert relative paths to absolute using base_path
319 if path.startswith("./"):
320 path = "/" + base_path + path[2:]
321 elif not path.startswith("/") and not path.startswith("http"):
322 path = "/" + base_path + path
323 return f'<a href="{path}">{text}</a>'
324
325 # Match [path | text] with flexible whitespace around the pipe
326 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
327 # Interwiki links: [wikipedia:Article] -> external link
328 content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
329 # Anchor links: [#anchor-name] -> local anchor
330 content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
331 # Bare wiki links: [PageName] (no pipe, not a URL)
332 content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
333
334 # Verbatim blocks
335 # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
336 def _render_pikchr_block(m):
337 try:
338 from fossil.cli import FossilCLI
339
340 cli = FossilCLI()
341 svg = cli.render_pikchr(m.group(1))
342 if svg:
343 return f'<div class="pikchr-diagram">{svg}</div>'
344 except Exception:
345 pass
346 return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
347
348 content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
349 # Regular verbatim blocks
350 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
351 # <nowiki> blocks — strip the tags, content passes through as-is
352 content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
353
354 # Convert Fossil wiki list syntax: * bullets and # enumeration
355 lines = content.split("\n")
356 result = []
357 in_list = False
358 list_type = "ul"
359 for line in lines:
360 stripped_line = line.strip()
361 is_bullet = re.match(r"^\*\s", stripped_line)
362 is_enum = re.match(r"^#\s", stripped_line) or re.match(�� it's Fossil wiki/HTP.PROJECT_VIEW.check(request.user)t math
363 th
364 import re
365 from datetime www/") for resolving relativnder
366 )
367 - Fossil-spimport contextlib
368 import re
369
370 import markdown as md
371 from django.contrib.auth.decorators import login_required
372 from django.404
373 ts import get_object_or_404, redirect, render
374 from django.utils.s
375 from projects.models import Project
376
377 from .models import FossilRepository
378 from .reader import FossilReader
379
380
381 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
382 """Render content that may be Fossil wiki markup, HTML, or Markdown.
383
384 Fossil wiki pages can contain:
385 - Raw HTML (most Fossil wiki pages)
386 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
387 - Markdown (newer pages)
388
389 base_path: directory of the current file (e.g. "www/") for resolving relative links.
390 """
391 if not content:
392 return ""
393
394 # Detect format from the raw content BEFORE any transformations
395 is_markdown = _is_markdown(content)
396
397 if is_markdown:
398 # Markdown: convert Fossil [path | text] links to markdown links first
399 def _fossil_to_md_link(m):
400 path = m.group(1).strip()
401 text = m.group(2).strip()
402 if path.startswith("./"):
403 path = "/" + base_path + path[2:]
404 elif not path.startswith("/") and not path.startswith("http"):
405 path = "/" + base_path + path
406 return f"[{text}]({path})"
407
408 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
409 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
410 html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
411
412 # Post-process: render pikchr fenced code blocks to SVG
413 def _render_pikchr_md(m):
414 try:
415 from fossil.cli import FossilCLI
416
417 cli = FossilCLI()
418 svg = cli.render_pikchr(m.group(1))
419 if svg:
420 timelinecontextlib
421 import matP.PROJECT_VIEW.check(request.user)t math
422 th
423 import re
424 from datetime django.contrib.auth.decorators import login_rimport math
425 [25, 50, 100]t re
426 from datetime tlib
427 import math
428 import re
429 from datetime import datetime
430
431 import markdown as md
432 from django.contrib.auth.decorators import login_required
433 from django.core.paginator import Paginator
434 from django.http import Http404, HttpResponse, JsonResponse
435 from django.shortcuts import get_object_or_404, redirect, render
436 from django.utils.safestring import mark_safe
437 from django.views.decorators.csrf import csrf_exempt
438
439 from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
440 from core.sanitize import sanitize_html
441 from proj,
442 }
443 contextlib
444 imporib
445 import math
446 import re
447 from datetwikipages": contextlib
448 import math
449 itextlib
450 import mathportforumposts": post# Render each post's body tl
451 from projects.models import import contextlib
452 impoiki CRUDiticketP.PROJECT_VIEW.check(request.user)t math
453 th
454 import re
455 from datetime actionontextlib
456 import mport re
457 from datetschedugit_ GitMirrordelete":
458 mirror_mirror_id")
459 mirror).firstmiirrorGit mirror removed."nort math
460 import reb
461 import mnotes": notewiki"-"):
462 mfile_diffs.append({"name": fname,}tags": tagcode"Raw File Downloadextlib
463 import math
464 import rport re
465 from datetime import datetime
466
467 import markdown as md
468 from django.contrib.auth.decorators import login_required
469 from django.core.paginator import Paginator
470 from django.http import Http404, HttpResponse, JsonResponse
471 from django.shortcuts import get_object_or_404, redirect, render
472 from django.utils.safestring import mark_safe
473 from django.views.decorators.csrf import csrf_exempt
474
475 from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
476 from core.sanitize import sanitize_html
477 from projects.models import Project
478
479 from .models import FossilRepository
480 from .reader import FossilReader
481
482
483 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
484 """Render content that may be Fossil wiki markup, HTML, or Markdown.
485
486 Fossil wiki pages can contain:
487 - Raw HTML (most Fossil wiki pages)
488 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
489 - Markdown (newer pages)
490
491 base_path: directory of the current file (e.g. "www/") for resolving relative links.
492 """
493 if not content:
494 return ""
495
496 # Det
497 imporib
498 import mathP.PROJECT_VIEW.check(request.user)t math
499 th
500 import re
501 from datetime www/") for resolving relativender
502 )
503 - Fossil-spimport contextlib
504 import re
505
506 import markdown as md
507 from django.contrib.auth.decorators import login_required
508 from django.404
509 ts import get_object_or_404, redirect, render
510 from django.utils.s
511 from projects.models import Project
512
513 from .models import FossilRepository
514 from .reader import FossilReader
515
516
517 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
518 """Render content that may be Fossil wiki markup, HTML, or Markdown.
519
520 Fossil wiki pages can contain:
521 - Raw HTML (most Fossil wiki pages)
522 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
523 - Markdown (newer pages)
524
525 base_path: directory of the current file (e.g. "www/") for resolving relative links.
526 """
527 if not content:
528 return ""
529
530 # Detect format from the raw content BEFORE any transformations
531 is_markdown = _is_markdown(content)
532
533 if is_markdnder
534 )
535 - Fossil-spimpP.PROJECT_VIEW.check(request.user)t math
536 th
537 import re
538 from datetime www/") for resolving relativndepage_name):
539 P.PROJEt math
540 th
541 import re
542 from datetime www/") for resolving relativnder str = "") -> str:
543 """Render content that may be Fossil wiki markup, HTML, or Markdown.
544
545 Fossil wiki pages can contain:
546 - Raw HTML (most Fossil wiki pages)
547 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
548 - Markdown (newer pages)
549
550 base_path: directory of the current file (e.g. "www/") for resolving relative links.
551 """
552 if not content:
553 return ""
554
555 # Detect format from the raw content BEFORE any transformations
556 is_markdown = _is_markdown(content)
557
558 if is_markdown:
559 # Markdown: convert Fossil [path | text] links to markdown links first
560 def _fossil_to_md_link(m):
561 path = m.group(1).strip()
562 text = m.group(2).strip()
563 if path.startswith("./"):
564 path = "/" + base_path + path[2:]
565 elif not path.startswith("/") and not path.startswith("http"):
566 path = "/" + base_path + path
567 return f"[{text}]({path})"
568
569 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
570 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
571 html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
572
573 # Post-process: render pikchr fenced code blocks to SVG
574 def _render_pikchr_md(m):
575 try:
576 from fossil.cli import FossilCLI
577
578 cli = FossilCLI()
579 svg = cli.render_pikchr(m.group(1))
580 if svg:
581 return f'<div class="pikchr-diagram">{svg}</div>'
582 except Exception:
583 pass
584 return m.group(0)
585
586 html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
587 return _rewrite_fossil_links(html, project_slug) if project_slug else html
588
589 # Fossil wiki / HTML: convert Fossil-specific syntax to HTML
590 # Fossil links: [path | text] or [path|text] — spaces around pipe are optional
591 def _fossil_link_replace(match):
592 path = match.group(1).strip()
593 text = match.group(2).strip()
594 # Convert relative paths to absolute using base_path
595 if path.startswith("./"):
596 path = "/" + base_path + path[2:]
597 elif not path.startswith("/") and not path.startswith("http"):
598 path = "/" + base_path + path
599 return f'<a href="{path}">{text}</a>'
600
601 # Match [path | text] with flexible whitespace around the pipe
602 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
603 # Interwiki links: [wikipedia:Article] -> external link
604 content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
605 # Anchor links: [#anchor-name] -> local anchor
606 content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
607 # Bare wiki links: [PageName] (no pipe, not a URL)
608 content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
609
610 # Verbatim blocks
611 # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
612 def _render_pikchr_block(m):
613 try:
614 from fossil.cli import FossilCLI
615
616 cli = FossilCLI()
617 svg = cli.render_pikchr(m.group(1))
618 if svg:
619 return f'<div class="pikchr-diagram">{svg}</div>'
620 except Exception:
621 pass
622 return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
623
624 content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
625 # Regular verbatim blocks
626 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
627 # <nowiki> blocks — strip the tags, content passes through as-is
628 content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
629
630 # Convert Fossil wiki list syntax: * bullets and # enumeration
631 lines = content.split("\n")
632 result = []
633 in_list = False
634 list_type = "ul"
635 for line in lines:
636 stripped_line = line.strip()
637 is_bullet = re.match(r"^\*\s", stripped_line)
638 is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
639 if is_bullet or is_enum:
640 new_type = "ol" if is_enum else "ul"
641 if not in_list:
642 list_type = new_type
643 result.append(f"<{list_type}>")
644 in_list = True
645 elif new_type != list_type:
646 result.append(f"</{list_type}>")
647 list_type = new_type
648 result.append(f"<{list_type}>")
649 item_text = re.sub(r"^[\*#\d+\.\)]\s*", "", stripped_line)
650 result.append(f"<li>{item_text}</li>")
651 else:
652 if in_list:
653 result.append(f"</{list_type}>")
654 in_list = False
655 result.append(line)
656 if in_list:
657 result.append(f"</{list_type}>")
658
659 content = "\n".join(result)
660
661 # Wrap bare text blocks in <p> tags (lines not inside HTML tags)
662 content = re.sub(r"\n\n(?!<)", "\n\n<p>", content)
663
664 return _rewrite_fossil_links(content, project_slug) if project_slug else content
665
666
667 def _is_markdown(content: str) -> bool:
668 """Detect if content is Markdown vs Fossil wiki/HTML.
669
670 Heuristic: if the content starts with markdown-style headings (#),
671 or has significant markdown syntax patterns, treat as markdown.
672 """
673 stripped = content.strip()
674 # Starts with markdown heading
675 if re.match(r"^#{1,6}\s", stripped):
676 return True
677 # Has multiple markdown headings
678 if len(re.findall(r"^#{1,6}\s", stripped, re.MULTILINE)) >= 2:
679 return True
680 # Has markdown link references [text][ref]
681 if re.search(r"\[.+\]\[.+\]", stripped):
682 return True
683 # Has markdown code fences
684 if "```" in stripped:
685 return True
686 # Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
687 return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
688
689
690 def _rewrite_fossil_links(html: str, project_slug: str) -> str:
691 """Rewrite internal Fossil URLs to our app's URL structure.
692
693 Fossil links like /doc/trunk/www/file.wiki, /info/HASH, /wiki/PageName,
694 /tktview/HASH get mapped to our fossil app URLs.
695 """
696 if not project_slug:
697 return html
698
699 base = f"/projects/{project_slug}/fossil"
700
701 def replace_link(match):
702 url = match.group(1)
703 # /info/HASH -> checkin detail
704 m = re.match(r"/info/([0-9a-f]+)", url)
705 if m:
706 return f'href="{base}/checkin/{m.group(1)}/"'
707 # /doc/trunk/www/file or /doc/tip/... -> code file view
708 m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", url)
709 if m:
710 return f'href="{base}/code/file/{m.group(1)}"'
711 # /wiki?name=PageName -> wiki page (query string format)
712 m = re.match(r"/wiki\?name=(.+)", url)
713 if m:
714 return f'href="{base}/wiki/page/{m.group(1)}"'
715 # /wiki/PageName -> wiki page (path format)
716 m = re.match(r"/wiki/(.+)", url)
717 if m:
718 return f'href="{base}/wiki/page/{m.group(1)}"'
719 # /tktview/HASH or /tktview?name=HASH -> ticket detail
720 m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
721 if m:
722 return f'href="{base}/tickets/{m.group(1)}/"'
723 # /vdiff?from=X&to=Y -> compare view
724 m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
725 if m:
726 return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
727 # /timeline -> timeline
728 if url.startswith("/timeline"):
729 return f'href="{base}/timeline/"'
730 # /forumpost/HASH -> forum thread
731 m = re.match(r"/forumpost/([0-9a-f]+)", url)
732 if m:
733 return f'href="{base}/forum/{m.group(1)}/"'
734 # /forum -> forum list
735 if url.startswith("/forum"):
736 return f'href="{base}/forum/"'
737 # /www/file.wiki or /www/subdir/file -> doc page viewer
738 m = re.match(r"/(www/.+)", url)
739 if m:
740 return f'href="{base}/docs/{m.group(1)}"'
741 # /help/command -> Fossil help (link to fossil docs)
742 m = re.match(r"/help/(.+)", url)
743 if m:
744 return f'href="{base}/docs/www/help.wiki"'
745 # Bare .wiki or .md file paths (from relative link resolution)
746 m = re.match(r"/([^/]+\.(?:wiki|md|html))", url)
747 if m:
748 return f'href="{base}/docs/www/{m.group(1)}"'
749 # /dir -> our code browser
750 if url == "/dir" or url.startswith("/dir?"):
751 return f'href="{base}/code/"'
752 # /builtin/path -> code file (these are embedded skin files)
753 m = re.match(r"/builtin/(.+)", url)
754 if m:
755 return f'href="{base}/code/file/skins/{m.group(1)}"'
756 # /setup_*, /admin_* -> Fossil server routes, no mapping
757 if re.match(r"/(setup_|admin_)", url):
758 return match.group(0)
759 # Keep external and unrecognized links as-is
760 Alsoto our local forumths (from relative link resorumfor resolvim relative link resolutioforumpost/([0-9a-f]+)", base}/docs/www/{m.group(1)}forum/{m.group(1)}/"'
761 result.appenforum/"'
762 return f'href="{base}/docs/etup_|admin_)", url):
763 rforumKeep external and unrecognized rum, html)((webhookwebhookwebhookwebhookrequest paramsstatus_paramtype}", type_param)url=secret=secref"Webhook for {url} crea# Only update secret (don't blank it on ediwebhookupdahtml = (html><head><title>{project.project.project.clone_url} {project.b
764 import math
765 import re
766 from datetime import datetime
767
768 import markdown as md
769 from djangotlib.")readme_code_fil":diff_lines.append({"text": line,t contextlib
770 importmport re
771 from dateti})
772 heckinport mark_safe
773 from django.views.decorators.csrf import csrf_exempt
774
775 from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
776 from core.sanitize import sanitize_html
777 from projects.models import Projecimint(per_page", "50"))per_page if per_pa contextlib
778 import math
779 import re
780 from datetime import datetime
781
782 import markdown as md
783 from django.contrib.auth.decorators import login_required
784 from django.core.paginator import Paginator
785 from django.http import Http404, HttpResponse, JsonResponse
786 from django.shortcuts import get_object_or_404, redirect, render
787 from django.utils.safestring import mark_safe
788 from django.views.decorators.csrf import csrf_exempt
789
790 from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
791 from core.sanitize import sanitize_html
792 from projects.models import Project
793
794 from .models import FossilRepository
795 from .reader import FossilReader
796
797
798 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
799 """Render content that may be Fossil wiki markup, HTML, or Markdown.
800
801 Fossil wiki pages can contain:
802 - Raw HTML (most Fossil wiki pages)
803 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
804 - Markdown (newer pages)
805
806 base_path: directory of the current file (e.g. "www/") for resolving relative links.
807 """
808 if not content:
809 return ""
810
811 # Detect format from the raw content BEFORE any transformations
812 is_markdown = _is_markdown(content)
813
814 if is_markdown:
815 # Markdown: convert Fossil [path | text] links to markdown links first
816 def _fossil_to_md_link(m):
817 path = m.group(1).strip()
818 text = m.group(2).strip()
819 if path.startswith("./"):
820 path = "/" + base_path + path[2:]
821 elif not path.startswith("/") and not path.startswith("http"):
822 path = "/" + base_path + path
823 return f"[{text}]({path})"
824
825 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
826 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
827 html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
828
829 # Post-process: render pikchr fenced code blocks to SVG
830 def _render_pikchr_md(m):
831 try:
832 from fossil.cli import FossilCLI
833
834 cli = FossilCLI()
835 svg = cli.render_pikchr(m.group(1))
836 if svg:
837 return f'<div class="pikchr-diagram">{svg}</div>'
838 except Exception:
839 pass
840 return m.group(0)
841
842 html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
843 return _rewrite_fossil_links(html, project_slug) if project_slug else html
844
845 # Fossil wiki / HTML: convert Fossil-specific syntax to HTML
846 # Fossil links: [path | text] or [path|text] — spaces around pipe are optional
847 def _fossil_link_replace(match):
848 path = match.group(1).strip()
849 text = match.group(2).strip()
850 # Convert relative paths to absolute using base_path
851 if path.startswith("./"):
852 path = "/" + base_path + path[2:]
853 elif not path.startswith("/") and not path.startswith("http"):
854 path = "/" + base_path + path
855 return f'<a href="{path}">{text}</a>'
856
857 # Match [path | text] with flexible whitespace around the pipe
858 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
859 # Interwiki links: [wikipedia:Article] -> external link
860 content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
861 # Anchor links: [#anchor-name] -> local anchor
862 content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
863 # Bare wiki links: [PageName] (no pipe, not a URL)
864 content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
865
866 # Verbatim blocks
867 # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
868 def _render_pikchr_block(m):
869 try:
870 from fossil.cli import FossilCLI
871
872 cli = FossilCLI()
873 svg = cli.render_pikchr(m.group(1))
874 if svg:
875 return f'<div class="pikchr-diagram">{svg}</div>'
876 except Exception:
877 pass
878 return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
879
880 content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
881 # Regular verbatim blocks
882 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
883 # <nowiki> blocks — strip the tags, content passes through as-is
884 content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
885
886 # Convert Fossil wiki list syntax: * bullets and # enumeration
887 lines = content.split("\n")
888 result = []
889 in_list = False
890 list_type = "ul"
891 for line in lines:
892 stripped_line = line.strip()
893 is_bullet = re.match(r"^\*\s", stripped_line)
894 is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
895 if is_bullet or is_enum:
896 new_type = "ol" if is_enum else "ul"
897 if not in_list:
898 list_type = new_type
899 result.append(f"<{list_type}>")
900 in_list = True
901 elif new_type != list_type:
902 result.append(f"</{list_type}>")
903 list_type = new_type
904 result.append(f"<{list_type}>")
905 item_text = re.sub(r"^[\*#\d+\.\)]\s*", "", stripped_line)
906 result.append(f"<li>{item_text}</li>")
907 else:
908 if in_list:
909 result.append(f"</{list_type}>")
910 in_list = False
911 result.append(line)
912 if in_list:
913 result.append(f"</{list_type}>")
914
915 content = "\n".join(result)
916
917 # Wrap bare text blocks in <p> tags (lines not inside HTML tags)
918 content = re.sub(r"\n\n(?!<)", "\n\n<p>", content)
919
920 return _rewrite_fossil_links(content, project_slug) if project_slug else content
921
922
923 def _is_markdown(content: str) -> bool:
924 """Detect if content is Markdown vs Fossil wiki/HTML.
925
926 Heuristic: if the content starts with markdown-style headings (#),
927 or has significant markdown syntax patterns, treat as markdown.
928 """
929 stripped = content.strip()
930 # Starts with markdown heading
931 if re.match(r"^#{1,6}\s", stripped):
932 return True
933 # Has multiple markdown headings
934 if len(re.findall(r"^#{1,6}\s", stripped, re.MULTILINE)) >= 2:
935 return True
936 # Has markdown link references [text][ref]
937 if re.search(r"\[.+\]\[.+\]", stripped):
938 return True
939 # Has markdown code fences
940 if "```" in stripped:
941 return True
942 # Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
943 return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
944
945
946 def _rewrite_fossil_links(html: str, project_slug: str) -> str:
947 """Rewrite internal Fossil URLs to our app's URL structure.
948
949 Fossil links like /doc/trunk/www/file.wiki, /info/HASH, /wiki/PageName,
950 /tktview/HASH get mapped to our fossil app URLs.
951 """
952 if not project_slug:
953 return html
954
955 base = f"/projects/{project_slug}/fossil"
956
957 def replace_link(match):
958 url = match.group(1)
959 # /info/HASH -> checkin detail
960 m = re.match(r"/info/([0-9a-f]+)", url)
961 if m:
962 return f'href="{base}/checkin/{m.group(1)}/"'
963 # /doc/trunk/www/file or /doc/tip/... -> code file view
964 m = re.match(r"/doc/(?:trunk|tip|[^/]+)/(.+)", url)
965 if m:
966 return f'href="{base}/code/file/{m.group(1)}"'
967 # /wiki?name=PageName -> wiki page (query string format)
968 m = re.match(r"/wiki\?name=(.+)", url)
969 if m:
970 return f'href="{base}/wiki/page/{m.group(1)}"'
971 # /wiki/PageName -> wiki page (path format)
972 m = re.match(r"/wiki/(.+)", url)
973 if m:
974 return f'href="{base}/wiki/page/{m.group(1)}"'
975 # /tktview/HASH or /tktview?name=HASH -> ticket detail
976 m = re.match(r"/tktview[?/](?:name=)?([0-9a-f]+)", url)
977 if m:
978 return f'href="{base}/tickets/{m.group(1)}/"'
979 # /vdiff?from=X&to=Y -> compare view
980 m = re.match(r"/vdiff\?from=([0-9a-f]+)&to=([0-9a-f]+)", url)
981 if m:
982 return f'href="{base}/compare/?from={m.group(1)}&to={m.group(2)}"'
983 # /timeline -> timeline
984 if url.startswith("/timeline"):
985 return f'href="{base}/timeline/"'
986 # /forumpost/HASH -> forum thread
987 m = re.match(r"/forumpost/([0-9a-f]+)", url)
988 if m:
989 return f'href="{base}/forum/{m.group(1)}/"'
990 # /forum -> forum list
991 if url.startswith("/forum"):
992 return f'href="{base}/forum/"'
993 # /www/file.wiki or /www/subdir/file -> doc page viewer
994 m = re.match(r"/(www/.+)", url)
995 if m:
996 return f'href="{base}/docs/{m.group(1)}"'
997 # /help/command -> Fossil help (link to fossil docs)
998 m = re.match(r"/help/(.+)", url)
999 if m:
1000 return f'href="{base}/docs/www/help.wiki"'
1001 # Bare .wiki or .md file paths (from relative link resolution)
1002 m = re.match(r"/([^/]+\.(?:wiki|md|html))", url)
1003 if m:
1004 return f'href="{base}/docs/www/{m.group(1)}"'
1005 # /dir -> our code browser
1006 if url == "/dir" or url.startswith("/dir?"):
1007 return f'href="{base}/code/"'
1008 # /builtin/path -> code file (these are embedded skin files)
1009 m = re.match(r"/builtin/(.+)", url)
1010 if m:
1011 return f'href="{base}/code/file/skins/{m.group(1)}"'
1012 # /setup_*, /admin_* -> Fossil server routes, no mapping
1013 if re.match(r"/(setup_|admin_)", url):
1014 return match.group(0)
1015 # Keep external and unrecognized links as-is
1016 Alsoto our local forumths (from relative link resorumfor resolvim relative link resolutioforumpost/([0-9a-f]+)", base}/docs/www/{m.group(1)}forum/{m.group(1)}/"'
1017 result.appenforum/"'
1018 return f'href="{base}/docs/etup_|admin_)", url):
1019 rforumKeep external and unrecognized rum, html)((webhookwebhookwebhookwebhookrequest paramsstatus_paramtype}", type_param)url=secret=secref"Webhook for {url} crea# Only update secret (don't blank it on ediwebhookupdahtml = (html><head><title>{project.project.project.clone_url} {project.b
1020 import math
1021 import re
1022 from datetime import datetime
1023
1024 import markdown as md
1025 from djangotlib.")import contextlibimport contextlib
1026 import math
1027 import re
1028 from datetime import datetime
1029
1030 import markdown as md
1031 from django.contrib.auth.decorators import login_rimport math
1032 [25, 50, 100]t re
1033 from datetime tlib
1034 import math
1035 import re
1036 from datetime import datetime
1037
1038 import markdown as md
1039 from django.contrib.auth.decorators import login_required
1040 from django.core.paginator import Paginator
1041 from django.http import Http404, HttpResponse, JsonResponse
1042 from django.shortcuts import get_object_or_404, redirect, render
1043 from django.utils.safestring import mark_safe
1044 from django.views.decorators.csrf import csrf_exempt
1045
1046 from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
1047 from core.sanitize import sanitize_html
1048 from proj,
1049 }
1050 contextlib
1051 imporib
1052 import math
1053 import re
1054 from datetwikipages": contextlib
1055 import math
1056 itextlib
1057 import mathportforumposts": post# Render each post's body tl
1058 from projects.models import import contextlib
1059 impoiki CRUDikirequest,}import contextlib
1060 importimport contextlib
1061 import math
1062 th
1063 import re
1064 from datetime import daactionontextlib
1065 import mport re
1066 from datetschedugit_ GitMirrordelete":
1067 mirror_mirror_id")
1068 mirror).firstmiirrorGit mirror removed."nort math
1069 import reb
1070 import mnotes": notewiki"-"):
1071 mfile_diffs.append({"name": fname,}tags": tagcode"Raw File Downloadextlib
1072 import math
1073 import rport re
1074 from datetime import datetime
1075
1076 import markdown as md
1077 from django.contrib.auth.decorators import login_required
1078 from django.core.paginator import Paginator
1079 from django.http import Http404, HttpResponse, JsonResponse
1080 from django.shortcuts import get_object_or_404, redirect, render
1081 from django.utils.safestring import mark_safe
1082 from django.views.decorators.csrf import csrf_exempt
1083
1084 from core.pagination import PER_PAGE_OPTIONS, get_per_page, manual_paginate
1085 from core.sanitize import sanitize_html
1086 from projects.models import Project
1087
1088 from .models import FossilRepository
1089 from .reader import FossilReader
1090
1091
1092 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
1093 """Render content that may be Fossil wiki markup, HTML, or Markdown.
1094
1095 Fossil wiki pages can contain:
1096 - Raw HTML (most Fossil wiki pages)
1097 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
1098 - Markdown (newer pages)
1099
1100 base_path: directory of the current file (e.g. "www/") for resolving relative links.
1101 """
1102 if not content:
1103 return ""
1104
1105 # Detect docdef
1106 """ = {}
1107 rid_to_railFor each row, compute:
1108 # 1. Which vertical rails are active (have a line passing through)
1109 # 2. Whether there's a fork/merge connector to drawto its parconnectors: for each row, collect all horizontal connections
1110 # A connector appears when a child on one rail connects to a parent
1111 # We draw the connector at BOTH the child row (fork out) and on every row where
1112 # a branch line needs to cross from one rail to anotherfor entryto markdown links firimport contextlib
1113 import ma or entry.parent_rid not= "/" + base_path + patimport.get, 0)
1114 :
1115 childchildparenparenconn = {"left": min(child_x, parent_x), "width": abs(child_x - parent_x)}
1116 # Draw at the parent's row (where branch meets trunk)
1117 parent_idxresultcontextlib
1118 imimport re
1119 f}
1120 sult
1121 OAuthGit sync triggered. Chender
1122 )
1123 - Fossil-spimport contextlib
1124 import re
1125
1126 import markdown as md
1127 from django.contrib.auth.decorators import login_required
1128 from django.404
1129 ts import get_object_or_404, redirect, render
1130 from django.utils.s
1131 from projects.models import Project
1132
1133 from .models import FossilRepository
1134 from .reader import FossilReader
1135
1136
1137 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
1138 """Render content that may be Fossil wiki markup, HTML, or Markdown.
1139
1140 Fossil wiki pages can contain:
1141 - Raw HTML (most FforumP.PROJECT_VIEW.check(request.user)t math
1142 th
1143 import re
1144 from datetime www/") for resolving relativnder
1145 )
1146 - Fossil-spimpP.PROJECT_VIEW.check(request.user)t math
1147 th
1148 import re
1149 from datetime www/") for resolving relativnder
1150 )
1151 - Fossil-spimport contextlib
1152 import re
1153
1154 import markdown as md
1155 from django.contrib.auth.decorators import login_required
1156 from django.404
1157 ts import get_object_or_404, redirect, render
1158 from django.utils.s
1159 from projects.models import Project
1160
1161 from .models import FossilRepository
1162 from .reader import FossilReader
1163
1164
1165 def _render_fossil_content(content: str, project_slug: str = "", base_path: str = "") -> str:
1166 """Render content that may be Fossil wiki markup, HTML, or Markdown.
1167
1168 Fossil wiki pages can contain:
1169 - Raw HTML (most Fossil wiki pages)
1170 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
1171 - Markdown (newer pages)
1172
1173 base_path: directory of the current file (e.g. "www/") for rt math
1174 th
1175 import re
1176 from datetime t math
1177 th
1178 import re
1179 from datetime www/") for resolving relativnder str = "") -> str:
1180 """Render content that may be Fossil wiki markup, HTML, or Markdown.
1181
1182 Fossil wiki pages can contain:
1183 - Raw HTML (most Fossil wiki pages)
1184 - Fossil-specific markup: [link|text], <verbatim>...</verbatim>
1185 - Markdown (newer pages)
1186
1187 base_path: directory of the current file (e.g. "www/") for resolving relative links.
1188 """
1189 if not content:
1190 return ""
1191
1192 # Detect format from the raw content BEFORE any transformations
1193 is_markdown = _is_markdown(content)
1194
1195 if is_markdown:
1196 # Markdown: convert Fossil [path | text] links to markdown links first
1197 def _fossil_to_md_link(m):
1198 path = m.group(1).strip()
1199 text = m.group(2).strip()
1200 if path.startswith("./"):
1201 path = "/" + base_path + path[2:]
1202 elif not path.startswith("/") and not path.startswith("http"):
1203 path = "/" + base_path + path
1204 return f"[{text}]({path})"
1205
1206 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_to_md_link, content)
1207 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"```\n\1\n```", content, flags=re.DOTALL)
1208 html = md.markdown(content, extensions=["fenced_code", "tables", "toc", "footnotes", "def_list", "attr_list"])
1209
1210 # Post-process: render pikchr fenced code blocks to SVG
1211 def _render_pikchr_md(m):
1212 try:
1213 from fossil.cli import FossilCLI
1214
1215 cli = FossilCLI()
1216 svg = cli.render_pikchr(m.group(1))
1217 if svg:
1218 return f'<div class="pikchr-diagram">{svg}</div>'
1219 except Exception:
1220 pass
1221 return m.group(0)
1222
1223 html = re.sub(r'<code class="language-pikchr">(.*?)</code>', _render_pikchr_md, html, flags=re.DOTALL)
1224 return _rewrite_fossil_links(html, project_slug) if project_slug else html
1225
1226 # Fossil wiki / HTML: convert Fossil-specific syntax to HTML
1227 # Fossil links: [path | text] or [path|text] — spaces around pipe are optional
1228 def _fossil_link_replace(match):
1229 path = match.group(1).strip()
1230 text = match.group(2).strip()
1231 # Convert relative paths to absolute using base_path
1232 if path.startswith("./"):
1233 path = "/" + base_path + path[2:]
1234 elif not path.startswith("/") and not path.startswith("http"):
1235 path = "/" + base_path + path
1236 return f'<a href="{path}">{text}</a>'
1237
1238 # Match [path | text] with flexible whitespace around the pipe
1239 content = re.sub(r"\[([^\]\|]+?)\s*\|\s*([^\]]+?)\]", _fossil_link_replace, content)
1240 # Interwiki links: [wikipedia:Article] -> external link
1241 content = re.sub(r"\[wikipedia:([^\]]+)\]", r'<a href="https://en.wikipedia.org/wiki/\1">\1</a>', content)
1242 # Anchor links: [#anchor-name] -> local anchor
1243 content = re.sub(r"\[#([^\]]+)\]", r'<a href="#\1">\1</a>', content)
1244 # Bare wiki links: [PageName] (no pipe, not a URL)
1245 content = re.sub(r"\[([A-Z][a-zA-Z0-9_]+)\]", r'<a href="\1">\1</a>', content)
1246
1247 # Verbatim blocks
1248 # Pikchr diagrams: <verbatim type="pikchr">...</verbatim> → SVG
1249 def _render_pikchr_block(m):
1250 try:
1251 from fossil.cli import FossilCLI
1252
1253 cli = FossilCLI()
1254 svg = cli.render_pikchr(m.group(1))
1255 if svg:
1256 return f'<div class="pikchr-diagram">{svg}</div>'
1257 except Exception:
1258 pass
1259 return f'<pre><code class="language-pikchr">{m.group(1)}</code></pre>'
1260
1261 content = re.sub(r'<verbatim\s+type="pikchr">(.*?)</verbatim>', _render_pikchr_block, content, flags=re.DOTALL)
1262 # Regular verbatim blocks
1263 content = re.sub(r"<verbatim>(.*?)</verbatim>", r"<pre><code>\1</code></pre>", content, flags=re.DOTALL)
1264 # <nowiki> blocks — strip the tags, content passes through as-is
1265 content = re.sub(r"<nowiki>(.*?)</nowiki>", r"\1", content, flags=re.DOTALL)
1266
1267 # Convert Fossil wiki list syntax: * bullets and # enumeration
1268 lines = content.split("\n")
1269 result = []
1270 in_list = False
1271 list_type = "ul"
1272 for line in lines:
1273 stripped_line = line.strip()
1274 is_bullet = re.match(r"^\*\s", stripped_line)
1275 is_enum = re.match(r"^#\s", stripped_line) or re.match(r"^\d+[\.\)]\s", stripped_line)
1276 if is_bullet or is_enum:
1277 new_type = "ol" if is_enum else "ul"
1278 if not in_list:
1279 list_type = new_type
1280 result.append(f"<{list_type}>")
1281 in_list = True
1282 elif new_type != list_

No diff available

--- a/items/admin.py
+++ b/items/admin.py
@@ -0,0 +1,12 @@
1
+from django.contrib import admin
2
+
3
+from core.admin import BaseCoreAdmin
4
+
5
+from .models import Item
6
+
7
+
8
+@admin.register(Item)
9
+class ItemAdmin(BaseCoreAdmin):
10
+ list_display = ("name", "slug", "price", "sku", "is_active", "created_at")
11
+ list_filter = ("is_active",)
12
+ search_fields = ("name", "slug", "sku")
--- a/items/admin.py
+++ b/items/admin.py
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
--- a/items/admin.py
+++ b/items/admin.py
@@ -0,0 +1,12 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import Item
6
7
8 @admin.register(Item)
9 class ItemAdmin(BaseCoreAdmin):
10 list_display = ("name", "slug", "price", "sku", "is_active", "created_at")
11 list_filter = ("is_active",)
12 search_fields = ("name", "slug", "sku")
--- a/items/apps.py
+++ b/items/apps.py
@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class ItemsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "items"
--- a/items/apps.py
+++ b/items/apps.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/items/apps.py
+++ b/items/apps.py
@@ -0,0 +1,6 @@
1 from django.apps import AppConfig
2
3
4 class ItemsConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "items"
--- a/items/forms.py
+++ b/items/forms.py
@@ -0,0 +1,18 @@
1
+from django import forms
2
+
3
+from .models import Item
4
+
5
+tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
6
+
7
+
8
+class ItemForm(forms.ModelForm):
9
+ class Meta:
10
+ model = Item
11
+ fields = ["name", "description", "price", "sku", "is_active"]
12
+ widgets = {
13
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "Item name"}),
14
+ "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
15
+ "price": forms.NumberInput(attrs={"class": tw, "step": "0.01", "placeholder": "0.00"}),
16
+ "sku": forms.TextInput(attrs={"class": tw, "placeholder": "SKU-001"}),
17
+ "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}),
18
+ }
--- a/items/forms.py
+++ b/items/forms.py
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/items/forms.py
+++ b/items/forms.py
@@ -0,0 +1,18 @@
1 from django import forms
2
3 from .models import Item
4
5 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
6
7
8 class ItemForm(forms.ModelForm):
9 class Meta:
10 model = Item
11 fields = ["name", "description", "price", "sku", "is_active"]
12 widgets = {
13 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Item name"}),
14 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
15 "price": forms.NumberInput(attrs={"class": tw, "step": "0.01", "placeholder": "0.00"}),
16 "sku": forms.TextInput(attrs={"class": tw, "placeholder": "SKU-001"}),
17 "is_active": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}),
18 }
--- a/items/migrations/0001_initial.py
+++ b/items/migrations/0001_initial.py
@@ -0,0 +1,168 @@
1
+# Generated by Django 5.2.12 on 2026-03-26 05:59
2
+
3
+import uuid
4
+
5
+import django.db.models.deletion
6
+import simple_history.models
7
+from django.conf import settings
8
+from django.db import migrations, models
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = [
15
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16
+ ]
17
+
18
+ operations = [
19
+ migrations.CreateModel(
20
+ name="HistoricalItem",
21
+ fields=[
22
+ (
23
+ "id",
24
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
25
+ ),
26
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
27
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
28
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
29
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
30
+ (
31
+ "guid",
32
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
33
+ ),
34
+ ("name", models.CharField(max_length=200)),
35
+ ("slug", models.SlugField(max_length=200)),
36
+ ("description", models.TextField(blank=True, default="")),
37
+ ("price", models.DecimalField(decimal_places=2, max_digits=10)),
38
+ (
39
+ "sku",
40
+ models.CharField(blank=True, db_index=True, default="", max_length=50),
41
+ ),
42
+ ("is_active", models.BooleanField(default=True)),
43
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
44
+ ("history_date", models.DateTimeField(db_index=True)),
45
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
46
+ (
47
+ "history_type",
48
+ models.CharField(
49
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
50
+ max_length=1,
51
+ ),
52
+ ),
53
+ (
54
+ "created_by",
55
+ models.ForeignKey(
56
+ blank=True,
57
+ db_constraint=False,
58
+ null=True,
59
+ on_delete=django.db.models.deletion.DO_NOTHING,
60
+ related_name="+",
61
+ to=settings.AUTH_USER_MODEL,
62
+ ),
63
+ ),
64
+ (
65
+ "deleted_by",
66
+ models.ForeignKey(
67
+ blank=True,
68
+ db_constraint=False,
69
+ null=True,
70
+ on_delete=django.db.models.deletion.DO_NOTHING,
71
+ related_name="+",
72
+ to=settings.AUTH_USER_MODEL,
73
+ ),
74
+ ),
75
+ (
76
+ "history_user",
77
+ models.ForeignKey(
78
+ null=True,
79
+ on_delete=django.db.models.deletion.SET_NULL,
80
+ related_name="+",
81
+ to=settings.AUTH_USER_MODEL,
82
+ ),
83
+ ),
84
+ (
85
+ "updated_by",
86
+ models.ForeignKey(
87
+ blank=True,
88
+ db_constraint=False,
89
+ null=True,
90
+ on_delete=django.db.models.deletion.DO_NOTHING,
91
+ related_name="+",
92
+ to=settings.AUTH_USER_MODEL,
93
+ ),
94
+ ),
95
+ ],
96
+ options={
97
+ "verbose_name": "historical item",
98
+ "verbose_name_plural": "historical items",
99
+ "ordering": ("-history_date", "-history_id"),
100
+ "get_latest_by": ("history_date", "history_id"),
101
+ },
102
+ bases=(simple_history.models.HistoricalChanges, models.Model),
103
+ ),
104
+ migrations.CreateModel(
105
+ name="Item",
106
+ fields=[
107
+ (
108
+ "id",
109
+ models.BigAutoField(
110
+ auto_created=True,
111
+ primary_key=True,
112
+ serialize=False,
113
+ verbose_name="ID",
114
+ ),
115
+ ),
116
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
117
+ ("created_at", models.DateTimeField(auto_now_add=True)),
118
+ ("updated_at", models.DateTimeField(auto_now=True)),
119
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
120
+ (
121
+ "guid",
122
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
123
+ ),
124
+ ("name", models.CharField(max_length=200)),
125
+ ("slug", models.SlugField(max_length=200, unique=True)),
126
+ ("description", models.TextField(blank=True, default="")),
127
+ ("price", models.DecimalField(decimal_places=2, max_digits=10)),
128
+ (
129
+ "sku",
130
+ models.CharField(blank=True, default="", max_length=50, unique=True),
131
+ ),
132
+ ("is_active", models.BooleanField(default=True)),
133
+ (
134
+ "created_by",
135
+ models.ForeignKey(
136
+ blank=True,
137
+ null=True,
138
+ on_delete=django.db.models.deletion.SET_NULL,
139
+ related_name="+",
140
+ to=settings.AUTH_USER_MODEL,
141
+ ),
142
+ ),
143
+ (
144
+ "deleted_by",
145
+ models.ForeignKey(
146
+ blank=True,
147
+ null=True,
148
+ on_delete=django.db.models.deletion.SET_NULL,
149
+ related_name="+",
150
+ to=settings.AUTH_USER_MODEL,
151
+ ),
152
+ ),
153
+ (
154
+ "updated_by",
155
+ models.ForeignKey(
156
+ blank=True,
157
+ null=True,
158
+ on_delete=django.db.models.deletion.SET_NULL,
159
+ related_name="+",
160
+ to=settings.AUTH_USER_MODEL,
161
+ ),
162
+ ),
163
+ ],
164
+ options={
165
+ "ordering": ["-created_at"],
166
+ },
167
+ ),
168
+ ]
--- a/items/migrations/0001_initial.py
+++ b/items/migrations/0001_initial.py
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/items/migrations/0001_initial.py
+++ b/items/migrations/0001_initial.py
@@ -0,0 +1,168 @@
1 # Generated by Django 5.2.12 on 2026-03-26 05:59
2
3 import uuid
4
5 import django.db.models.deletion
6 import simple_history.models
7 from django.conf import settings
8 from django.db import migrations, models
9
10
11 class Migration(migrations.Migration):
12 initial = True
13
14 dependencies = [
15 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16 ]
17
18 operations = [
19 migrations.CreateModel(
20 name="HistoricalItem",
21 fields=[
22 (
23 "id",
24 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
25 ),
26 ("version", models.PositiveIntegerField(default=1, editable=False)),
27 ("created_at", models.DateTimeField(blank=True, editable=False)),
28 ("updated_at", models.DateTimeField(blank=True, editable=False)),
29 ("deleted_at", models.DateTimeField(blank=True, null=True)),
30 (
31 "guid",
32 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
33 ),
34 ("name", models.CharField(max_length=200)),
35 ("slug", models.SlugField(max_length=200)),
36 ("description", models.TextField(blank=True, default="")),
37 ("price", models.DecimalField(decimal_places=2, max_digits=10)),
38 (
39 "sku",
40 models.CharField(blank=True, db_index=True, default="", max_length=50),
41 ),
42 ("is_active", models.BooleanField(default=True)),
43 ("history_id", models.AutoField(primary_key=True, serialize=False)),
44 ("history_date", models.DateTimeField(db_index=True)),
45 ("history_change_reason", models.CharField(max_length=100, null=True)),
46 (
47 "history_type",
48 models.CharField(
49 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
50 max_length=1,
51 ),
52 ),
53 (
54 "created_by",
55 models.ForeignKey(
56 blank=True,
57 db_constraint=False,
58 null=True,
59 on_delete=django.db.models.deletion.DO_NOTHING,
60 related_name="+",
61 to=settings.AUTH_USER_MODEL,
62 ),
63 ),
64 (
65 "deleted_by",
66 models.ForeignKey(
67 blank=True,
68 db_constraint=False,
69 null=True,
70 on_delete=django.db.models.deletion.DO_NOTHING,
71 related_name="+",
72 to=settings.AUTH_USER_MODEL,
73 ),
74 ),
75 (
76 "history_user",
77 models.ForeignKey(
78 null=True,
79 on_delete=django.db.models.deletion.SET_NULL,
80 related_name="+",
81 to=settings.AUTH_USER_MODEL,
82 ),
83 ),
84 (
85 "updated_by",
86 models.ForeignKey(
87 blank=True,
88 db_constraint=False,
89 null=True,
90 on_delete=django.db.models.deletion.DO_NOTHING,
91 related_name="+",
92 to=settings.AUTH_USER_MODEL,
93 ),
94 ),
95 ],
96 options={
97 "verbose_name": "historical item",
98 "verbose_name_plural": "historical items",
99 "ordering": ("-history_date", "-history_id"),
100 "get_latest_by": ("history_date", "history_id"),
101 },
102 bases=(simple_history.models.HistoricalChanges, models.Model),
103 ),
104 migrations.CreateModel(
105 name="Item",
106 fields=[
107 (
108 "id",
109 models.BigAutoField(
110 auto_created=True,
111 primary_key=True,
112 serialize=False,
113 verbose_name="ID",
114 ),
115 ),
116 ("version", models.PositiveIntegerField(default=1, editable=False)),
117 ("created_at", models.DateTimeField(auto_now_add=True)),
118 ("updated_at", models.DateTimeField(auto_now=True)),
119 ("deleted_at", models.DateTimeField(blank=True, null=True)),
120 (
121 "guid",
122 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
123 ),
124 ("name", models.CharField(max_length=200)),
125 ("slug", models.SlugField(max_length=200, unique=True)),
126 ("description", models.TextField(blank=True, default="")),
127 ("price", models.DecimalField(decimal_places=2, max_digits=10)),
128 (
129 "sku",
130 models.CharField(blank=True, default="", max_length=50, unique=True),
131 ),
132 ("is_active", models.BooleanField(default=True)),
133 (
134 "created_by",
135 models.ForeignKey(
136 blank=True,
137 null=True,
138 on_delete=django.db.models.deletion.SET_NULL,
139 related_name="+",
140 to=settings.AUTH_USER_MODEL,
141 ),
142 ),
143 (
144 "deleted_by",
145 models.ForeignKey(
146 blank=True,
147 null=True,
148 on_delete=django.db.models.deletion.SET_NULL,
149 related_name="+",
150 to=settings.AUTH_USER_MODEL,
151 ),
152 ),
153 (
154 "updated_by",
155 models.ForeignKey(
156 blank=True,
157 null=True,
158 on_delete=django.db.models.deletion.SET_NULL,
159 related_name="+",
160 to=settings.AUTH_USER_MODEL,
161 ),
162 ),
163 ],
164 options={
165 "ordering": ["-created_at"],
166 },
167 ),
168 ]
--- a/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
+++ b/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
@@ -0,0 +1,22 @@
1
+# Generated by Django 5.2.12 on 2026-03-26 06:01
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("items", "0001_initial"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AlterField(
13
+ model_name="historicalitem",
14
+ name="sku",
15
+ field=models.CharField(blank=True, db_index=True, default=None, max_length=50, null=True),
16
+ ),
17
+ migrations.AlterField(
18
+ model_name="item",
19
+ name="sku",
20
+ field=models.CharField(blank=True, default=None, max_length=50, null=True, unique=True),
21
+ ),
22
+ ]
--- a/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
+++ b/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
+++ b/items/migrations/0002_alter_historicalitem_sku_alter_item_sku.py
@@ -0,0 +1,22 @@
1 # Generated by Django 5.2.12 on 2026-03-26 06:01
2
3 from django.db import migrations, models
4
5
6 class Migration(migrations.Migration):
7 dependencies = [
8 ("items", "0001_initial"),
9 ]
10
11 operations = [
12 migrations.AlterField(
13 model_name="historicalitem",
14 name="sku",
15 field=models.CharField(blank=True, db_index=True, default=None, max_length=50, null=True),
16 ),
17 migrations.AlterField(
18 model_name="item",
19 name="sku",
20 field=models.CharField(blank=True, default=None, max_length=50, null=True, unique=True),
21 ),
22 ]

No diff available

--- a/items/models.py
+++ b/items/models.py
@@ -0,0 +1,15 @@
1
+from django.db import models
2
+
3
+from core.models import ActiveManager, BaseCoreModel
4
+
5
+
6
+class Item(BaseCoreModel):
7
+ price = models.DecimalField(max_digits=10, decimal_places=2)
8
+ sku = models.CharField(max_length=50, unique=True, blank=True, null=True, default=None)
9
+ is_active = models.BooleanField(default=True)
10
+
11
+ objects = ActiveManager()
12
+ all_objects = models.Manager()
13
+
14
+ class Meta:
15
+ ordering = ["-created_at"]
--- a/items/models.py
+++ b/items/models.py
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/items/models.py
+++ b/items/models.py
@@ -0,0 +1,15 @@
1 from django.db import models
2
3 from core.models import ActiveManager, BaseCoreModel
4
5
6 class Item(BaseCoreModel):
7 price = models.DecimalField(max_digits=10, decimal_places=2)
8 sku = models.CharField(max_length=50, unique=True, blank=True, null=True, default=None)
9 is_active = models.BooleanField(default=True)
10
11 objects = ActiveManager()
12 all_objects = models.Manager()
13
14 class Meta:
15 ordering = ["-created_at"]
--- a/items/tests.py
+++ b/items/tests.py
@@ -0,0 +1,133 @@
1
+import pytest
2
+from django.urls import reverse
3
+
4
+from .models import Item
5
+
6
+
7
+@pytest.fixture
8
+def sample_item(db, admin_user):
9
+ return Item.objects.create(name="Test Widget", price="29.99", sku="TST-001", created_by=admin_user)
10
+
11
+
12
+@pytest.mark.django_db
13
+class TestItemList:
14
+ def test_list_requires_login(self, client):
15
+ response = client.get(reverse("items:list"))
16
+ assert response.status_code == 302
17
+
18
+ def test_list_renders_for_superuser(self, admin_client, sample_item):
19
+ response = admin_client.get(reverse("items:list"))
20
+ assert response.status_code == 200
21
+ assert b"Test Widget" in response.content
22
+
23
+ def test_list_renders_for_viewer(self, viewer_client, sample_item):
24
+ response = viewer_client.get(reverse("items:list"))
25
+ assert response.status_code == 200
26
+ assert b"Test Widget" in response.content
27
+
28
+ def test_list_denied_for_user_without_perm(self, no_perm_client, sample_item):
29
+ response = no_perm_client.get(reverse("items:list"))
30
+ assert response.status_code == 403
31
+
32
+ def test_list_htmx_returns_partial(self, admin_client, sample_item):
33
+ response = admin_client.get(reverse("items:list"), HTTP_HX_REQUEST="true")
34
+ assert response.status_code == 200
35
+ assert b"item-table" in response.content
36
+ assert b"<!DOCTYPE" not in response.content # partial, not full page
37
+
38
+ def test_list_search_filters(self, admin_client, admin_user):
39
+ Item.objects.create(name="Alpha", price="10.00", created_by=admin_user)
40
+ Item.objects.create(name="Beta", price="20.00", created_by=admin_user)
41
+ response = admin_client.get(reverse("items:list") + "?search=Alpha")
42
+ assert b"Alpha" in response.content
43
+ assert b"Beta" not in response.content
44
+
45
+
46
+@pytest.mark.django_db
47
+class TestItemCreate:
48
+ def test_create_form_renders(self, admin_client):
49
+ response = admin_client.get(reverse("items:create"))
50
+ assert response.status_code == 200
51
+ assert b"New Item" in response.content
52
+
53
+ def test_create_saves_item(self, admin_client, admin_user):
54
+ response = admin_client.post(
55
+ reverse("items:create"),
56
+ {"name": "New Gadget", "description": "A new gadget", "price": "49.99", "sku": "NGT-001", "is_active": True},
57
+ )
58
+ assert response.status_code == 302
59
+ item = Item.objects.get(sku="NGT-001")
60
+ assert item.name == "New Gadget"
61
+ assert item.created_by == admin_user
62
+
63
+ def test_create_denied_for_viewer(self, viewer_client):
64
+ response = viewer_client.get(reverse("items:create"))
65
+ assert response.status_code == 403
66
+
67
+ def test_create_invalid_data_shows_errors(self, admin_client):
68
+ response = admin_client.post(reverse("items:create"), {"name": "", "price": ""})
69
+ assert response.status_code == 200 # re-renders form with errors
70
+
71
+
72
+@pytest.mark.django_db
73
+class TestItemDetail:
74
+ def test_detail_renders(self, admin_client, sample_item):
75
+ response = admin_client.get(reverse("items:detail", kwargs={"slug": sample_item.slug}))
76
+ assert response.status_code == 200
77
+ assert b"Test Widget" in response.content
78
+ assert str(sample_item.guid).encode() in response.content
79
+
80
+ def test_detail_404_for_deleted(self, admin_client, sample_item, admin_user):
81
+ sample_item.soft_delete(user=admin_user)
82
+ response = admin_client.get(reverse("items:detail", kwargs={"slug": sample_item.slug}))
83
+ assert response.status_code == 404
84
+
85
+
86
+@pytest.mark.django_db
87
+class TestItemUpdate:
88
+ def test_update_form_renders(self, admin_client, sample_item):
89
+ response = admin_client.get(reverse("items:update", kwargs={"slug": sample_item.slug}))
90
+ assert response.status_code == 200
91
+ assert b"Edit Item" in response.content
92
+
93
+ def test_update_saves_changes(self, admin_client, sample_item):
94
+ response = admin_client.post(
95
+ reverse("items:update", kwargs={"slug": sample_item.slug}),
96
+ {"name": "Updated Widget", "description": "Updated", "price": "39.99", "sku": "TST-001", "is_active": True},
97
+ )
98
+ assert response.status_code == 302
99
+ sample_item.refresh_from_db()
100
+ assert sample_item.name == "Updated Widget"
101
+ from decimal import Decimal
102
+
103
+ assert sample_item.price == Decimal("39.99")
104
+
105
+ def test_update_denied_for_viewer(self, viewer_client, sample_item):
106
+ response = viewer_client.get(reverse("items:update", kwargs={"slug": sample_item.slug}))
107
+ assert response.status_code == 403
108
+
109
+
110
+@pytest.mark.django_db
111
+class TestItemDelete:
112
+ def test_delete_confirm_renders(self, admin_client, sample_item):
113
+ response = admin_client.get(reverse("items:delete", kwargs={"slug": sample_item.slug}))
114
+ assert response.status_code == 200
115
+ assert b"Delete Item" in response.content
116
+
117
+ def test_delete_soft_deletes(self, admin_client, sample_item):
118
+ response = admin_client.post(reverse("items:delete", kwargs={"slug": sample_item.slug}))
119
+ assert response.status_code == 302
120
+ sample_item.refresh_from_db()
121
+ assert sample_item.is_deleted
122
+
123
+ def test_delete_htmx_returns_redirect_header(self, admin_client, sample_item):
124
+ response = admin_client.post(
125
+ reverse("items:delete", kwargs={"slug": sample_item.slug}),
126
+ HTTP_HX_REQUEST="true",
127
+ )
128
+ assert response.status_code == 200
129
+ assert response.headers.get("HX-Redirect") == "/items/"
130
+
131
+ def test_delete_denied_for_viewer(self, viewer_client, sample_item):
132
+ response = viewer_client.post(reverse("items:delete", kwargs={"slug": sample_item.slug}))
133
+ assert response.status_code == 403
--- a/items/tests.py
+++ b/items/tests.py
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/items/tests.py
+++ b/items/tests.py
@@ -0,0 +1,133 @@
1 import pytest
2 from django.urls import reverse
3
4 from .models import Item
5
6
7 @pytest.fixture
8 def sample_item(db, admin_user):
9 return Item.objects.create(name="Test Widget", price="29.99", sku="TST-001", created_by=admin_user)
10
11
12 @pytest.mark.django_db
13 class TestItemList:
14 def test_list_requires_login(self, client):
15 response = client.get(reverse("items:list"))
16 assert response.status_code == 302
17
18 def test_list_renders_for_superuser(self, admin_client, sample_item):
19 response = admin_client.get(reverse("items:list"))
20 assert response.status_code == 200
21 assert b"Test Widget" in response.content
22
23 def test_list_renders_for_viewer(self, viewer_client, sample_item):
24 response = viewer_client.get(reverse("items:list"))
25 assert response.status_code == 200
26 assert b"Test Widget" in response.content
27
28 def test_list_denied_for_user_without_perm(self, no_perm_client, sample_item):
29 response = no_perm_client.get(reverse("items:list"))
30 assert response.status_code == 403
31
32 def test_list_htmx_returns_partial(self, admin_client, sample_item):
33 response = admin_client.get(reverse("items:list"), HTTP_HX_REQUEST="true")
34 assert response.status_code == 200
35 assert b"item-table" in response.content
36 assert b"<!DOCTYPE" not in response.content # partial, not full page
37
38 def test_list_search_filters(self, admin_client, admin_user):
39 Item.objects.create(name="Alpha", price="10.00", created_by=admin_user)
40 Item.objects.create(name="Beta", price="20.00", created_by=admin_user)
41 response = admin_client.get(reverse("items:list") + "?search=Alpha")
42 assert b"Alpha" in response.content
43 assert b"Beta" not in response.content
44
45
46 @pytest.mark.django_db
47 class TestItemCreate:
48 def test_create_form_renders(self, admin_client):
49 response = admin_client.get(reverse("items:create"))
50 assert response.status_code == 200
51 assert b"New Item" in response.content
52
53 def test_create_saves_item(self, admin_client, admin_user):
54 response = admin_client.post(
55 reverse("items:create"),
56 {"name": "New Gadget", "description": "A new gadget", "price": "49.99", "sku": "NGT-001", "is_active": True},
57 )
58 assert response.status_code == 302
59 item = Item.objects.get(sku="NGT-001")
60 assert item.name == "New Gadget"
61 assert item.created_by == admin_user
62
63 def test_create_denied_for_viewer(self, viewer_client):
64 response = viewer_client.get(reverse("items:create"))
65 assert response.status_code == 403
66
67 def test_create_invalid_data_shows_errors(self, admin_client):
68 response = admin_client.post(reverse("items:create"), {"name": "", "price": ""})
69 assert response.status_code == 200 # re-renders form with errors
70
71
72 @pytest.mark.django_db
73 class TestItemDetail:
74 def test_detail_renders(self, admin_client, sample_item):
75 response = admin_client.get(reverse("items:detail", kwargs={"slug": sample_item.slug}))
76 assert response.status_code == 200
77 assert b"Test Widget" in response.content
78 assert str(sample_item.guid).encode() in response.content
79
80 def test_detail_404_for_deleted(self, admin_client, sample_item, admin_user):
81 sample_item.soft_delete(user=admin_user)
82 response = admin_client.get(reverse("items:detail", kwargs={"slug": sample_item.slug}))
83 assert response.status_code == 404
84
85
86 @pytest.mark.django_db
87 class TestItemUpdate:
88 def test_update_form_renders(self, admin_client, sample_item):
89 response = admin_client.get(reverse("items:update", kwargs={"slug": sample_item.slug}))
90 assert response.status_code == 200
91 assert b"Edit Item" in response.content
92
93 def test_update_saves_changes(self, admin_client, sample_item):
94 response = admin_client.post(
95 reverse("items:update", kwargs={"slug": sample_item.slug}),
96 {"name": "Updated Widget", "description": "Updated", "price": "39.99", "sku": "TST-001", "is_active": True},
97 )
98 assert response.status_code == 302
99 sample_item.refresh_from_db()
100 assert sample_item.name == "Updated Widget"
101 from decimal import Decimal
102
103 assert sample_item.price == Decimal("39.99")
104
105 def test_update_denied_for_viewer(self, viewer_client, sample_item):
106 response = viewer_client.get(reverse("items:update", kwargs={"slug": sample_item.slug}))
107 assert response.status_code == 403
108
109
110 @pytest.mark.django_db
111 class TestItemDelete:
112 def test_delete_confirm_renders(self, admin_client, sample_item):
113 response = admin_client.get(reverse("items:delete", kwargs={"slug": sample_item.slug}))
114 assert response.status_code == 200
115 assert b"Delete Item" in response.content
116
117 def test_delete_soft_deletes(self, admin_client, sample_item):
118 response = admin_client.post(reverse("items:delete", kwargs={"slug": sample_item.slug}))
119 assert response.status_code == 302
120 sample_item.refresh_from_db()
121 assert sample_item.is_deleted
122
123 def test_delete_htmx_returns_redirect_header(self, admin_client, sample_item):
124 response = admin_client.post(
125 reverse("items:delete", kwargs={"slug": sample_item.slug}),
126 HTTP_HX_REQUEST="true",
127 )
128 assert response.status_code == 200
129 assert response.headers.get("HX-Redirect") == "/items/"
130
131 def test_delete_denied_for_viewer(self, viewer_client, sample_item):
132 response = viewer_client.post(reverse("items:delete", kwargs={"slug": sample_item.slug}))
133 assert response.status_code == 403
--- a/items/urls.py
+++ b/items/urls.py
@@ -0,0 +1,13 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "items"
6
+
7
+urlpatterns = [
8
+ path("", views.item_list, name="list"),
9
+ path("create/", views.item_create, name="create"),
10
+ path("<slug:slug>/", views.item_detail, name="detail"),
11
+ path("<slug:slug>/edit/", views.item_update, name="update"),
12
+ path("<slug:slug>/delete/", views.item_delete, name="delete"),
13
+]
--- a/items/urls.py
+++ b/items/urls.py
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/items/urls.py
+++ b/items/urls.py
@@ -0,0 +1,13 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "items"
6
7 urlpatterns = [
8 path("", views.item_list, name="list"),
9 path("create/", views.item_create, name="create"),
10 path("<slug:slug>/", views.item_detail, name="detail"),
11 path("<slug:slug>/edit/", views.item_update, name="update"),
12 path("<slug:slug>/delete/", views.item_delete, name="delete"),
13 ]
--- a/items/views.py
+++ b/items/views.py
@@ -0,0 +1,86 @@
1
+from django.contrib import messages
2
+from django.contrib.auth.decorators import login_required
3
+from django.shortcuts import get_object_or_404, redirect, render
4
+
5
+from core.permissions import P
6
+
7
+from .forms import ItemForm
8
+from .models import Item
9
+
10
+
11
+@login_required
12
+def item_list(request):
13
+ P.ITEM_VIEW.check(request.user)
14
+ items = Item.objects.all()
15
+
16
+ search = request.GET.get("search", "").strip()
17
+ if search:
18
+ items = items.filter(name__icontains=search)
19
+
20
+ if request.headers.get("HX-Request"):
21
+ return render(request, "items/partials/item_table.html", {"items": items})
22
+
23
+ return render(request, "items/item_list.html", {"items": items, "search": search})
24
+
25
+
26
+@login_required
27
+def item_create(request):
28
+ P.ITEM_ADD.check(request.user)
29
+
30
+ if request.method == "POST":
31
+ form = ItemForm(request.POST)
32
+ if form.is_valid():
33
+ item = form.save(commit=False)
34
+ item.created_by = request.user
35
+ item.save()
36
+ messages.success(request, f'Item "{item.name}" created.')
37
+ return redirect("items:detail", slug=item.slug)
38
+ else:
39
+ form = ItemForm()
40
+
41
+ return render(request, "items/item_form.html", {"form": form, "title": "New Item"})
42
+
43
+
44
+@login_required
45
+def item_detail(request, slug):
46
+ P.ITEM_VIEW.check(request.user)
47
+ item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True)
48
+ return render(request, "items/item_detail.html", {"item": item})
49
+
50
+
51
+@login_required
52
+def item_update(request, slug):
53
+ P.ITEM_CHANGE.check(request.user)
54
+ item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True)
55
+
56
+ if request.method == "POST":
57
+ form = ItemForm(request.POST, instance=item)
58
+ if form.is_valid():
59
+ item = form.save(commit=False)
60
+ item.updated_by = request.user
61
+ item.save()
62
+ messages.success(request, f'Item "{item.name}" updated.')
63
+ return redirect("items:detail", slug=item.slug)
64
+ else:
65
+ form = ItemForm(instance=item)
66
+
67
+ return render(request, "items/item_form.html", {"form": form, "item": item, "title": "Edit Item"})
68
+
69
+
70
+@login_required
71
+def item_delete(request, slug):
72
+ P.ITEM_DELETE.check(request.user)
73
+ item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True)
74
+
75
+ if request.method == "POST":
76
+ item.soft_delete(user=request.user)
77
+ messages.success(request, f'Item "{item.name}" deleted.')
78
+
79
+ if request.headers.get("HX-Request"):
80
+ from django.http import HttpResponse
81
+
82
+ return HttpResponse(status=200, headers={"HX-Redirect": "/items/"})
83
+
84
+ return redirect("items:list")
85
+
86
+ return render(request, "items/item_confirm_delete.html", {"item": item})
--- a/items/views.py
+++ b/items/views.py
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/items/views.py
+++ b/items/views.py
@@ -0,0 +1,86 @@
1 from django.contrib import messages
2 from django.contrib.auth.decorators import login_required
3 from django.shortcuts import get_object_or_404, redirect, render
4
5 from core.permissions import P
6
7 from .forms import ItemForm
8 from .models import Item
9
10
11 @login_required
12 def item_list(request):
13 P.ITEM_VIEW.check(request.user)
14 items = Item.objects.all()
15
16 search = request.GET.get("search", "").strip()
17 if search:
18 items = items.filter(name__icontains=search)
19
20 if request.headers.get("HX-Request"):
21 return render(request, "items/partials/item_table.html", {"items": items})
22
23 return render(request, "items/item_list.html", {"items": items, "search": search})
24
25
26 @login_required
27 def item_create(request):
28 P.ITEM_ADD.check(request.user)
29
30 if request.method == "POST":
31 form = ItemForm(request.POST)
32 if form.is_valid():
33 item = form.save(commit=False)
34 item.created_by = request.user
35 item.save()
36 messages.success(request, f'Item "{item.name}" created.')
37 return redirect("items:detail", slug=item.slug)
38 else:
39 form = ItemForm()
40
41 return render(request, "items/item_form.html", {"form": form, "title": "New Item"})
42
43
44 @login_required
45 def item_detail(request, slug):
46 P.ITEM_VIEW.check(request.user)
47 item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True)
48 return render(request, "items/item_detail.html", {"item": item})
49
50
51 @login_required
52 def item_update(request, slug):
53 P.ITEM_CHANGE.check(request.user)
54 item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True)
55
56 if request.method == "POST":
57 form = ItemForm(request.POST, instance=item)
58 if form.is_valid():
59 item = form.save(commit=False)
60 item.updated_by = request.user
61 item.save()
62 messages.success(request, f'Item "{item.name}" updated.')
63 return redirect("items:detail", slug=item.slug)
64 else:
65 form = ItemForm(instance=item)
66
67 return render(request, "items/item_form.html", {"form": form, "item": item, "title": "Edit Item"})
68
69
70 @login_required
71 def item_delete(request, slug):
72 P.ITEM_DELETE.check(request.user)
73 item = get_object_or_404(Item, slug=slug, deleted_at__isnull=True)
74
75 if request.method == "POST":
76 item.soft_delete(user=request.user)
77 messages.success(request, f'Item "{item.name}" deleted.')
78
79 if request.headers.get("HX-Request"):
80 from django.http import HttpResponse
81
82 return HttpResponse(status=200, headers={"HX-Redirect": "/items/"})
83
84 return redirect("items:list")
85
86 return render(request, "items/item_confirm_delete.html", {"item": item})
+14
--- a/manage.py
+++ b/manage.py
@@ -0,0 +1,14 @@
1
+#!/usr/bin/env python
2
+import os
3
+import sys
4
+
5
+
6
+def main():
7
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
8
+ from django.core.management import execute_from_command_line
9
+
10
+ execute_from_command_line(sys.argv)
11
+
12
+
13
+if __name__ == "__main__":
14
+ main()
--- a/manage.py
+++ b/manage.py
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/manage.py
+++ b/manage.py
@@ -0,0 +1,14 @@
1 #!/usr/bin/env python
2 import os
3 import sys
4
5
6 def main():
7 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
8 from django.core.management import execute_from_command_line
9
10 execute_from_command_line(sys.argv)
11
12
13 if __name__ == "__main__":
14 main()

No diff available

--- a/organization/admin.py
+++ b/organization/admin.py
@@ -0,0 +1,26 @@
1
+from django.contrib import admin
2
+
3
+from core.admin import BaseCoreAdmin
4
+
5
+from .models import OrganizTeam
6
+
7
+dmin.register(OrganMemberInline(admin.TabularInline):
8
+ model = OrganizationMember
9
+ extra = 0
10
+ raw_id_fields = ("member",)
11
+
12
+
13
+@admin.register(Organization)
14
+class OrganizationAdmin(BaseCoreAdmin):
15
+ list_display = ("name", "slug", "website", "created_at")
16
+ search_fields = ("name", "slug")
17
+ inlines = [OrganizationMemberInline]
18
+
19
+
20
+@admin.register(Team)
21
+class TeamAdmin(BaseCoreAdmin):
22
+ list_display = ("name", "slug", "organization", "created_at")
23
+ search_fields = ("name", "slug")
24
+ , "organization", "role", "is_)izationMember
25
+ extra = 0
26
+ "organization")
--- a/organization/admin.py
+++ b/organization/admin.py
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/admin.py
+++ b/organization/admin.py
@@ -0,0 +1,26 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import OrganizTeam
6
7 dmin.register(OrganMemberInline(admin.TabularInline):
8 model = OrganizationMember
9 extra = 0
10 raw_id_fields = ("member",)
11
12
13 @admin.register(Organization)
14 class OrganizationAdmin(BaseCoreAdmin):
15 list_display = ("name", "slug", "website", "created_at")
16 search_fields = ("name", "slug")
17 inlines = [OrganizationMemberInline]
18
19
20 @admin.register(Team)
21 class TeamAdmin(BaseCoreAdmin):
22 list_display = ("name", "slug", "organization", "created_at")
23 search_fields = ("name", "slug")
24 , "organization", "role", "is_)izationMember
25 extra = 0
26 "organization")
--- a/organization/apps.py
+++ b/organization/apps.py
@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class OrganizationConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "organization"
--- a/organization/apps.py
+++ b/organization/apps.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/organization/apps.py
+++ b/organization/apps.py
@@ -0,0 +1,6 @@
1 from django.apps import AppConfig
2
3
4 class OrganizationConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "organization"
--- a/organization/forms.py
+++ b/organization/forms.py
@@ -0,0 +1,55 @@
1
+from django import forms
2
+from django.contrib.auth.models import User
3
+
4
+from .mTeam
5
+
6
+tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
7
+
8
+
9
+class OrganizationSettingsForm(forms.ModelForm):
10
+ class Meta:
11
+ model = Organization
12
+ fields = ["name", "description", "website"]
13
+ widgets = {
14
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "Organization name"}),
15
+ "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
16
+ "website": forms.URLInput(attrs={"class": tw, "placeholder": "https://example.com"}),
17
+ }
18
+
19
+
20
+class MemberAddForm(forms.Form):
21
+ user = forms.ModelChoiceField(
22
+ queryset=User.objects.none(),
23
+ widget=forms.Select(attrs={"class": tw}),
24
+ label="User",
25
+ )
26
+
27
+ def __init__(self, *args, org=None, **kwargs):
28
+ super().__init__(*args, **kwargs)
29
+ if org:
30
+ existing_member_ids = org.members.filter(deleted_at__isnull=True).values_list("member_id", flat=True)
31
+ self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
32
+
33
+
34
+class TeamForm(forms.ModelForm):
35
+ class Meta:
36
+ model = Team
37
+ fields = ["name", "description"]
38
+ widgets = {
39
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "Team name"}),
40
+ "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
41
+ }
42
+
43
+
44
+class TeamMemberAddForm(forms.Form):
45
+ user = forms.ModelChoiceField(
46
+ queryset=User.objects.none(),
47
+ widget=forms.Select(attrs={"class": tw}),
48
+ label="User",
49
+ )
50
+
51
+ def __init__(self, *args, team=None, **kwargs):
52
+ super().__init__(*args, **kwargs)
53
+ if team:
54
+ existing_member_ids = team.members.values_list("id", flat=True)
55
+ self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
--- a/organization/forms.py
+++ b/organization/forms.py
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/forms.py
+++ b/organization/forms.py
@@ -0,0 +1,55 @@
1 from django import forms
2 from django.contrib.auth.models import User
3
4 from .mTeam
5
6 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
7
8
9 class OrganizationSettingsForm(forms.ModelForm):
10 class Meta:
11 model = Organization
12 fields = ["name", "description", "website"]
13 widgets = {
14 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Organization name"}),
15 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
16 "website": forms.URLInput(attrs={"class": tw, "placeholder": "https://example.com"}),
17 }
18
19
20 class MemberAddForm(forms.Form):
21 user = forms.ModelChoiceField(
22 queryset=User.objects.none(),
23 widget=forms.Select(attrs={"class": tw}),
24 label="User",
25 )
26
27 def __init__(self, *args, org=None, **kwargs):
28 super().__init__(*args, **kwargs)
29 if org:
30 existing_member_ids = org.members.filter(deleted_at__isnull=True).values_list("member_id", flat=True)
31 self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
32
33
34 class TeamForm(forms.ModelForm):
35 class Meta:
36 model = Team
37 fields = ["name", "description"]
38 widgets = {
39 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Team name"}),
40 "description": forms.Textarea(attrs={"class": tw, "rows": 3, "placeholder": "Description"}),
41 }
42
43
44 class TeamMemberAddForm(forms.Form):
45 user = forms.ModelChoiceField(
46 queryset=User.objects.none(),
47 widget=forms.Select(attrs={"class": tw}),
48 label="User",
49 )
50
51 def __init__(self, *args, team=None, **kwargs):
52 super().__init__(*args, **kwargs)
53 if team:
54 existing_member_ids = team.members.values_list("id", flat=True)
55 self.fields["user"].queryset = User.objects.filter(is_active=True).exclude(id__in=existing_member_ids)
--- a/organization/migrations/0001_initial.py
+++ b/organization/migrations/0001_initial.py
@@ -0,0 +1,330 @@
1
+# Generated by Django 5.2.12 on 2026-03-26 05:59
2
+
3
+import uuid
4
+
5
+import django.db.models.deletion
6
+import simple_history.models
7
+from django.conf import settings
8
+from django.db import migrations, models
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ("auth", "0012_alter_user_first_name_max_length"),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name="HistoricalOrganization",
22
+ fields=[
23
+ (
24
+ "id",
25
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26
+ ),
27
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
28
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
29
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
30
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
31
+ (
32
+ "guid",
33
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34
+ ),
35
+ ("name", models.CharField(max_length=200)),
36
+ ("slug", models.SlugField(max_length=200)),
37
+ ("description", models.TextField(blank=True, default="")),
38
+ ("website", models.URLField(blank=True, default="")),
39
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
40
+ ("history_date", models.DateTimeField(db_index=True)),
41
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
42
+ (
43
+ "history_type",
44
+ models.CharField(
45
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
46
+ max_length=1,
47
+ ),
48
+ ),
49
+ (
50
+ "created_by",
51
+ models.ForeignKey(
52
+ blank=True,
53
+ db_constraint=False,
54
+ null=True,
55
+ on_delete=django.db.models.deletion.DO_NOTHING,
56
+ related_name="+",
57
+ to=settings.AUTH_USER_MODEL,
58
+ ),
59
+ ),
60
+ (
61
+ "deleted_by",
62
+ models.ForeignKey(
63
+ blank=True,
64
+ db_constraint=False,
65
+ null=True,
66
+ on_delete=django.db.models.deletion.DO_NOTHING,
67
+ related_name="+",
68
+ to=settings.AUTH_USER_MODEL,
69
+ ),
70
+ ),
71
+ (
72
+ "history_user",
73
+ models.ForeignKey(
74
+ null=True,
75
+ on_delete=django.db.models.deletion.SET_NULL,
76
+ related_name="+",
77
+ to=settings.AUTH_USER_MODEL,
78
+ ),
79
+ ),
80
+ (
81
+ "updated_by",
82
+ models.ForeignKey(
83
+ blank=True,
84
+ db_constraint=False,
85
+ null=True,
86
+ on_delete=django.db.models.deletion.DO_NOTHING,
87
+ related_name="+",
88
+ to=settings.AUTH_USER_MODEL,
89
+ ),
90
+ ),
91
+ ],
92
+ options={
93
+ "verbose_name": "historical organization",
94
+ "verbose_name_plural": "historical organizations",
95
+ "ordering": ("-history_date", "-history_id"),
96
+ "get_latest_by": ("history_date", "history_id"),
97
+ },
98
+ bases=(simple_history.models.HistoricalChanges, models.Model),
99
+ ),
100
+ migrations.CreateModel(
101
+ name="Organization",
102
+ fields=[
103
+ (
104
+ "id",
105
+ models.BigAutoField(
106
+ auto_created=True,
107
+ primary_key=True,
108
+ serialize=False,
109
+ verbose_name="ID",
110
+ ),
111
+ ),
112
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
113
+ ("created_at", models.DateTimeField(auto_now_add=True)),
114
+ ("updated_at", models.DateTimeField(auto_now=True)),
115
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
116
+ (
117
+ "guid",
118
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
119
+ ),
120
+ ("name", models.CharField(max_length=200)),
121
+ ("slug", models.SlugField(max_length=200, unique=True)),
122
+ ("description", models.TextField(blank=True, default="")),
123
+ ("website", models.URLField(blank=True, default="")),
124
+ (
125
+ "created_by",
126
+ models.ForeignKey(
127
+ blank=True,
128
+ null=True,
129
+ on_delete=django.db.models.deletion.SET_NULL,
130
+ related_name="+",
131
+ to=settings.AUTH_USER_MODEL,
132
+ ),
133
+ ),
134
+ (
135
+ "deleted_by",
136
+ models.ForeignKey(
137
+ blank=True,
138
+ null=True,
139
+ on_delete=django.db.models.deletion.SET_NULL,
140
+ related_name="+",
141
+ to=settings.AUTH_USER_MODEL,
142
+ ),
143
+ ),
144
+ (
145
+ "groups",
146
+ models.ManyToManyField(blank=True, related_name="organizations", to="auth.group"),
147
+ ),
148
+ (
149
+ "updated_by",
150
+ models.ForeignKey(
151
+ blank=True,
152
+ null=True,
153
+ on_delete=django.db.models.deletion.SET_NULL,
154
+ related_name="+",
155
+ to=settings.AUTH_USER_MODEL,
156
+ ),
157
+ ),
158
+ ],
159
+ options={
160
+ "ordering": ["name"],
161
+ },
162
+ ),
163
+ migrations.CreateModel(
164
+ name="HistoricalOrganizationMember",
165
+ fields=[
166
+ (
167
+ "id",
168
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
169
+ ),
170
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
171
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
172
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
173
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
174
+ ("is_active", models.BooleanField(default=True)),
175
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
176
+ ("history_date", models.DateTimeField(db_index=True)),
177
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
178
+ (
179
+ "history_type",
180
+ models.CharField(
181
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
182
+ max_length=1,
183
+ ),
184
+ ),
185
+ (
186
+ "created_by",
187
+ models.ForeignKey(
188
+ blank=True,
189
+ db_constraint=False,
190
+ null=True,
191
+ on_delete=django.db.models.deletion.DO_NOTHING,
192
+ related_name="+",
193
+ to=settings.AUTH_USER_MODEL,
194
+ ),
195
+ ),
196
+ (
197
+ "deleted_by",
198
+ models.ForeignKey(
199
+ blank=True,
200
+ db_constraint=False,
201
+ null=True,
202
+ on_delete=django.db.models.deletion.DO_NOTHING,
203
+ related_name="+",
204
+ to=settings.AUTH_USER_MODEL,
205
+ ),
206
+ ),
207
+ (
208
+ "history_user",
209
+ models.ForeignKey(
210
+ null=True,
211
+ on_delete=django.db.models.deletion.SET_NULL,
212
+ related_name="+",
213
+ to=settings.AUTH_USER_MODEL,
214
+ ),
215
+ ),
216
+ (
217
+ "member",
218
+ models.ForeignKey(
219
+ blank=True,
220
+ db_constraint=False,
221
+ null=True,
222
+ on_delete=django.db.models.deletion.DO_NOTHING,
223
+ related_name="+",
224
+ to=settings.AUTH_USER_MODEL,
225
+ ),
226
+ ),
227
+ (
228
+ "updated_by",
229
+ models.ForeignKey(
230
+ blank=True,
231
+ db_constraint=False,
232
+ null=True,
233
+ on_delete=django.db.models.deletion.DO_NOTHING,
234
+ related_name="+",
235
+ to=settings.AUTH_USER_MODEL,
236
+ ),
237
+ ),
238
+ (
239
+ "organization",
240
+ models.ForeignKey(
241
+ blank=True,
242
+ db_constraint=False,
243
+ null=True,
244
+ on_delete=django.db.models.deletion.DO_NOTHING,
245
+ related_name="+",
246
+ to="organization.organization",
247
+ ),
248
+ ),
249
+ ],
250
+ options={
251
+ "verbose_name": "historical organization member",
252
+ "verbose_name_plural": "historical organization members",
253
+ "ordering": ("-history_date", "-history_id"),
254
+ "get_latest_by": ("history_date", "history_id"),
255
+ },
256
+ bases=(simple_history.models.HistoricalChanges, models.Model),
257
+ ),
258
+ migrations.CreateModel(
259
+ name="OrganizationMember",
260
+ fields=[
261
+ (
262
+ "id",
263
+ models.BigAutoField(
264
+ auto_created=True,
265
+ primary_key=True,
266
+ serialize=False,
267
+ verbose_name="ID",
268
+ ),
269
+ ),
270
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
271
+ ("created_at", models.DateTimeField(auto_now_add=True)),
272
+ ("updated_at", models.DateTimeField(auto_now=True)),
273
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
274
+ ("is_active", models.BooleanField(default=True)),
275
+ (
276
+ "created_by",
277
+ models.ForeignKey(
278
+ blank=True,
279
+ null=True,
280
+ on_delete=django.db.models.deletion.SET_NULL,
281
+ related_name="+",
282
+ to=settings.AUTH_USER_MODEL,
283
+ ),
284
+ ),
285
+ (
286
+ "deleted_by",
287
+ models.ForeignKey(
288
+ blank=True,
289
+ null=True,
290
+ on_delete=django.db.models.deletion.SET_NULL,
291
+ related_name="+",
292
+ to=settings.AUTH_USER_MODEL,
293
+ ),
294
+ ),
295
+ (
296
+ "groups",
297
+ models.ManyToManyField(blank=True, related_name="org_memberships", to="auth.group"),
298
+ ),
299
+ (
300
+ "member",
301
+ models.ForeignKey(
302
+ on_delete=django.db.models.deletion.CASCADE,
303
+ related_name="memberships",
304
+ to=settings.AUTH_USER_MODEL,
305
+ ),
306
+ ),
307
+ (
308
+ "organization",
309
+ models.ForeignKey(
310
+ on_delete=django.db.models.deletion.CASCADE,
311
+ related_name="members",
312
+ to="organization.organization",
313
+ ),
314
+ ),
315
+ (
316
+ "updated_by",
317
+ models.ForeignKey(
318
+ blank=True,
319
+ null=True,
320
+ on_delete=django.db.models.deletion.SET_NULL,
321
+ related_name="+",
322
+ to=settings.AUTH_USER_MODEL,
323
+ ),
324
+ ),
325
+ ],
326
+ options={
327
+ "unique_together": {("member", "organization")},
328
+ },
329
+ ),
330
+ ]
--- a/organization/migrations/0001_initial.py
+++ b/organization/migrations/0001_initial.py
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/migrations/0001_initial.py
+++ b/organization/migrations/0001_initial.py
@@ -0,0 +1,330 @@
1 # Generated by Django 5.2.12 on 2026-03-26 05:59
2
3 import uuid
4
5 import django.db.models.deletion
6 import simple_history.models
7 from django.conf import settings
8 from django.db import migrations, models
9
10
11 class Migration(migrations.Migration):
12 initial = True
13
14 dependencies = [
15 ("auth", "0012_alter_user_first_name_max_length"),
16 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17 ]
18
19 operations = [
20 migrations.CreateModel(
21 name="HistoricalOrganization",
22 fields=[
23 (
24 "id",
25 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26 ),
27 ("version", models.PositiveIntegerField(default=1, editable=False)),
28 ("created_at", models.DateTimeField(blank=True, editable=False)),
29 ("updated_at", models.DateTimeField(blank=True, editable=False)),
30 ("deleted_at", models.DateTimeField(blank=True, null=True)),
31 (
32 "guid",
33 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34 ),
35 ("name", models.CharField(max_length=200)),
36 ("slug", models.SlugField(max_length=200)),
37 ("description", models.TextField(blank=True, default="")),
38 ("website", models.URLField(blank=True, default="")),
39 ("history_id", models.AutoField(primary_key=True, serialize=False)),
40 ("history_date", models.DateTimeField(db_index=True)),
41 ("history_change_reason", models.CharField(max_length=100, null=True)),
42 (
43 "history_type",
44 models.CharField(
45 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
46 max_length=1,
47 ),
48 ),
49 (
50 "created_by",
51 models.ForeignKey(
52 blank=True,
53 db_constraint=False,
54 null=True,
55 on_delete=django.db.models.deletion.DO_NOTHING,
56 related_name="+",
57 to=settings.AUTH_USER_MODEL,
58 ),
59 ),
60 (
61 "deleted_by",
62 models.ForeignKey(
63 blank=True,
64 db_constraint=False,
65 null=True,
66 on_delete=django.db.models.deletion.DO_NOTHING,
67 related_name="+",
68 to=settings.AUTH_USER_MODEL,
69 ),
70 ),
71 (
72 "history_user",
73 models.ForeignKey(
74 null=True,
75 on_delete=django.db.models.deletion.SET_NULL,
76 related_name="+",
77 to=settings.AUTH_USER_MODEL,
78 ),
79 ),
80 (
81 "updated_by",
82 models.ForeignKey(
83 blank=True,
84 db_constraint=False,
85 null=True,
86 on_delete=django.db.models.deletion.DO_NOTHING,
87 related_name="+",
88 to=settings.AUTH_USER_MODEL,
89 ),
90 ),
91 ],
92 options={
93 "verbose_name": "historical organization",
94 "verbose_name_plural": "historical organizations",
95 "ordering": ("-history_date", "-history_id"),
96 "get_latest_by": ("history_date", "history_id"),
97 },
98 bases=(simple_history.models.HistoricalChanges, models.Model),
99 ),
100 migrations.CreateModel(
101 name="Organization",
102 fields=[
103 (
104 "id",
105 models.BigAutoField(
106 auto_created=True,
107 primary_key=True,
108 serialize=False,
109 verbose_name="ID",
110 ),
111 ),
112 ("version", models.PositiveIntegerField(default=1, editable=False)),
113 ("created_at", models.DateTimeField(auto_now_add=True)),
114 ("updated_at", models.DateTimeField(auto_now=True)),
115 ("deleted_at", models.DateTimeField(blank=True, null=True)),
116 (
117 "guid",
118 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
119 ),
120 ("name", models.CharField(max_length=200)),
121 ("slug", models.SlugField(max_length=200, unique=True)),
122 ("description", models.TextField(blank=True, default="")),
123 ("website", models.URLField(blank=True, default="")),
124 (
125 "created_by",
126 models.ForeignKey(
127 blank=True,
128 null=True,
129 on_delete=django.db.models.deletion.SET_NULL,
130 related_name="+",
131 to=settings.AUTH_USER_MODEL,
132 ),
133 ),
134 (
135 "deleted_by",
136 models.ForeignKey(
137 blank=True,
138 null=True,
139 on_delete=django.db.models.deletion.SET_NULL,
140 related_name="+",
141 to=settings.AUTH_USER_MODEL,
142 ),
143 ),
144 (
145 "groups",
146 models.ManyToManyField(blank=True, related_name="organizations", to="auth.group"),
147 ),
148 (
149 "updated_by",
150 models.ForeignKey(
151 blank=True,
152 null=True,
153 on_delete=django.db.models.deletion.SET_NULL,
154 related_name="+",
155 to=settings.AUTH_USER_MODEL,
156 ),
157 ),
158 ],
159 options={
160 "ordering": ["name"],
161 },
162 ),
163 migrations.CreateModel(
164 name="HistoricalOrganizationMember",
165 fields=[
166 (
167 "id",
168 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
169 ),
170 ("version", models.PositiveIntegerField(default=1, editable=False)),
171 ("created_at", models.DateTimeField(blank=True, editable=False)),
172 ("updated_at", models.DateTimeField(blank=True, editable=False)),
173 ("deleted_at", models.DateTimeField(blank=True, null=True)),
174 ("is_active", models.BooleanField(default=True)),
175 ("history_id", models.AutoField(primary_key=True, serialize=False)),
176 ("history_date", models.DateTimeField(db_index=True)),
177 ("history_change_reason", models.CharField(max_length=100, null=True)),
178 (
179 "history_type",
180 models.CharField(
181 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
182 max_length=1,
183 ),
184 ),
185 (
186 "created_by",
187 models.ForeignKey(
188 blank=True,
189 db_constraint=False,
190 null=True,
191 on_delete=django.db.models.deletion.DO_NOTHING,
192 related_name="+",
193 to=settings.AUTH_USER_MODEL,
194 ),
195 ),
196 (
197 "deleted_by",
198 models.ForeignKey(
199 blank=True,
200 db_constraint=False,
201 null=True,
202 on_delete=django.db.models.deletion.DO_NOTHING,
203 related_name="+",
204 to=settings.AUTH_USER_MODEL,
205 ),
206 ),
207 (
208 "history_user",
209 models.ForeignKey(
210 null=True,
211 on_delete=django.db.models.deletion.SET_NULL,
212 related_name="+",
213 to=settings.AUTH_USER_MODEL,
214 ),
215 ),
216 (
217 "member",
218 models.ForeignKey(
219 blank=True,
220 db_constraint=False,
221 null=True,
222 on_delete=django.db.models.deletion.DO_NOTHING,
223 related_name="+",
224 to=settings.AUTH_USER_MODEL,
225 ),
226 ),
227 (
228 "updated_by",
229 models.ForeignKey(
230 blank=True,
231 db_constraint=False,
232 null=True,
233 on_delete=django.db.models.deletion.DO_NOTHING,
234 related_name="+",
235 to=settings.AUTH_USER_MODEL,
236 ),
237 ),
238 (
239 "organization",
240 models.ForeignKey(
241 blank=True,
242 db_constraint=False,
243 null=True,
244 on_delete=django.db.models.deletion.DO_NOTHING,
245 related_name="+",
246 to="organization.organization",
247 ),
248 ),
249 ],
250 options={
251 "verbose_name": "historical organization member",
252 "verbose_name_plural": "historical organization members",
253 "ordering": ("-history_date", "-history_id"),
254 "get_latest_by": ("history_date", "history_id"),
255 },
256 bases=(simple_history.models.HistoricalChanges, models.Model),
257 ),
258 migrations.CreateModel(
259 name="OrganizationMember",
260 fields=[
261 (
262 "id",
263 models.BigAutoField(
264 auto_created=True,
265 primary_key=True,
266 serialize=False,
267 verbose_name="ID",
268 ),
269 ),
270 ("version", models.PositiveIntegerField(default=1, editable=False)),
271 ("created_at", models.DateTimeField(auto_now_add=True)),
272 ("updated_at", models.DateTimeField(auto_now=True)),
273 ("deleted_at", models.DateTimeField(blank=True, null=True)),
274 ("is_active", models.BooleanField(default=True)),
275 (
276 "created_by",
277 models.ForeignKey(
278 blank=True,
279 null=True,
280 on_delete=django.db.models.deletion.SET_NULL,
281 related_name="+",
282 to=settings.AUTH_USER_MODEL,
283 ),
284 ),
285 (
286 "deleted_by",
287 models.ForeignKey(
288 blank=True,
289 null=True,
290 on_delete=django.db.models.deletion.SET_NULL,
291 related_name="+",
292 to=settings.AUTH_USER_MODEL,
293 ),
294 ),
295 (
296 "groups",
297 models.ManyToManyField(blank=True, related_name="org_memberships", to="auth.group"),
298 ),
299 (
300 "member",
301 models.ForeignKey(
302 on_delete=django.db.models.deletion.CASCADE,
303 related_name="memberships",
304 to=settings.AUTH_USER_MODEL,
305 ),
306 ),
307 (
308 "organization",
309 models.ForeignKey(
310 on_delete=django.db.models.deletion.CASCADE,
311 related_name="members",
312 to="organization.organization",
313 ),
314 ),
315 (
316 "updated_by",
317 models.ForeignKey(
318 blank=True,
319 null=True,
320 on_delete=django.db.models.deletion.SET_NULL,
321 related_name="+",
322 to=settings.AUTH_USER_MODEL,
323 ),
324 ),
325 ],
326 options={
327 "unique_together": {("member", "organization")},
328 },
329 ),
330 ]
--- a/organization/migrations/0002_historicalteam_team.py
+++ b/organization/migrations/0002_historicalteam_team.py
@@ -0,0 +1,178 @@
1
+# Generated by Django 5.2.12 on 2026-04-06 01:08
2
+
3
+import uuid
4
+
5
+import django.db.models.deletion
6
+import simple_history.models
7
+from django.conf import settings
8
+from django.db import migrations, models
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ dependencies = [
13
+ ("organization", "0001_initial"),
14
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="HistoricalTeam",
20
+ fields=[
21
+ (
22
+ "id",
23
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
24
+ ),
25
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
26
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
27
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
28
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
29
+ (
30
+ "guid",
31
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
32
+ ),
33
+ ("name", models.CharField(max_length=200)),
34
+ ("slug", models.SlugField(max_length=200)),
35
+ ("description", models.TextField(blank=True, default="")),
36
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
37
+ ("history_date", models.DateTimeField(db_index=True)),
38
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
39
+ (
40
+ "history_type",
41
+ models.CharField(
42
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
43
+ max_length=1,
44
+ ),
45
+ ),
46
+ (
47
+ "created_by",
48
+ models.ForeignKey(
49
+ blank=True,
50
+ db_constraint=False,
51
+ null=True,
52
+ on_delete=django.db.models.deletion.DO_NOTHING,
53
+ related_name="+",
54
+ to=settings.AUTH_USER_MODEL,
55
+ ),
56
+ ),
57
+ (
58
+ "deleted_by",
59
+ models.ForeignKey(
60
+ blank=True,
61
+ db_constraint=False,
62
+ null=True,
63
+ on_delete=django.db.models.deletion.DO_NOTHING,
64
+ related_name="+",
65
+ to=settings.AUTH_USER_MODEL,
66
+ ),
67
+ ),
68
+ (
69
+ "history_user",
70
+ models.ForeignKey(
71
+ null=True,
72
+ on_delete=django.db.models.deletion.SET_NULL,
73
+ related_name="+",
74
+ to=settings.AUTH_USER_MODEL,
75
+ ),
76
+ ),
77
+ (
78
+ "organization",
79
+ models.ForeignKey(
80
+ blank=True,
81
+ db_constraint=False,
82
+ null=True,
83
+ on_delete=django.db.models.deletion.DO_NOTHING,
84
+ related_name="+",
85
+ to="organization.organization",
86
+ ),
87
+ ),
88
+ (
89
+ "updated_by",
90
+ models.ForeignKey(
91
+ blank=True,
92
+ db_constraint=False,
93
+ null=True,
94
+ on_delete=django.db.models.deletion.DO_NOTHING,
95
+ related_name="+",
96
+ to=settings.AUTH_USER_MODEL,
97
+ ),
98
+ ),
99
+ ],
100
+ options={
101
+ "verbose_name": "historical team",
102
+ "verbose_name_plural": "historical teams",
103
+ "ordering": ("-history_date", "-history_id"),
104
+ "get_latest_by": ("history_date", "history_id"),
105
+ },
106
+ bases=(simple_history.models.HistoricalChanges, models.Model),
107
+ ),
108
+ migrations.CreateModel(
109
+ name="Team",
110
+ fields=[
111
+ (
112
+ "id",
113
+ models.BigAutoField(
114
+ auto_created=True,
115
+ primary_key=True,
116
+ serialize=False,
117
+ verbose_name="ID",
118
+ ),
119
+ ),
120
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
121
+ ("created_at", models.DateTimeField(auto_now_add=True)),
122
+ ("updated_at", models.DateTimeField(auto_now=True)),
123
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
124
+ (
125
+ "guid",
126
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
127
+ ),
128
+ ("name", models.CharField(max_length=200)),
129
+ ("slug", models.SlugField(max_length=200, unique=True)),
130
+ ("description", models.TextField(blank=True, default="")),
131
+ (
132
+ "created_by",
133
+ models.ForeignKey(
134
+ blank=True,
135
+ null=True,
136
+ on_delete=django.db.models.deletion.SET_NULL,
137
+ related_name="+",
138
+ to=settings.AUTH_USER_MODEL,
139
+ ),
140
+ ),
141
+ (
142
+ "deleted_by",
143
+ models.ForeignKey(
144
+ blank=True,
145
+ null=True,
146
+ on_delete=django.db.models.deletion.SET_NULL,
147
+ related_name="+",
148
+ to=settings.AUTH_USER_MODEL,
149
+ ),
150
+ ),
151
+ (
152
+ "members",
153
+ models.ManyToManyField(blank=True, related_name="teams", to=settings.AUTH_USER_MODEL),
154
+ ),
155
+ (
156
+ "organization",
157
+ models.ForeignKey(
158
+ on_delete=django.db.models.deletion.CASCADE,
159
+ related_name="teams",
160
+ to="organization.organization",
161
+ ),
162
+ ),
163
+ (
164
+ "updated_by",
165
+ models.ForeignKey(
166
+ blank=True,
167
+ null=True,
168
+ on_delete=django.db.models.deletion.SET_NULL,
169
+ related_name="+",
170
+ to=settings.AUTH_USER_MODEL,
171
+ ),
172
+ ),
173
+ ],
174
+ options={
175
+ "ordering": ["name"],
176
+ },
177
+ ),
178
+ ]
--- a/organization/migrations/0002_historicalteam_team.py
+++ b/organization/migrations/0002_historicalteam_team.py
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/migrations/0002_historicalteam_team.py
+++ b/organization/migrations/0002_historicalteam_team.py
@@ -0,0 +1,178 @@
1 # Generated by Django 5.2.12 on 2026-04-06 01:08
2
3 import uuid
4
5 import django.db.models.deletion
6 import simple_history.models
7 from django.conf import settings
8 from django.db import migrations, models
9
10
11 class Migration(migrations.Migration):
12 dependencies = [
13 ("organization", "0001_initial"),
14 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 ]
16
17 operations = [
18 migrations.CreateModel(
19 name="HistoricalTeam",
20 fields=[
21 (
22 "id",
23 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
24 ),
25 ("version", models.PositiveIntegerField(default=1, editable=False)),
26 ("created_at", models.DateTimeField(blank=True, editable=False)),
27 ("updated_at", models.DateTimeField(blank=True, editable=False)),
28 ("deleted_at", models.DateTimeField(blank=True, null=True)),
29 (
30 "guid",
31 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
32 ),
33 ("name", models.CharField(max_length=200)),
34 ("slug", models.SlugField(max_length=200)),
35 ("description", models.TextField(blank=True, default="")),
36 ("history_id", models.AutoField(primary_key=True, serialize=False)),
37 ("history_date", models.DateTimeField(db_index=True)),
38 ("history_change_reason", models.CharField(max_length=100, null=True)),
39 (
40 "history_type",
41 models.CharField(
42 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
43 max_length=1,
44 ),
45 ),
46 (
47 "created_by",
48 models.ForeignKey(
49 blank=True,
50 db_constraint=False,
51 null=True,
52 on_delete=django.db.models.deletion.DO_NOTHING,
53 related_name="+",
54 to=settings.AUTH_USER_MODEL,
55 ),
56 ),
57 (
58 "deleted_by",
59 models.ForeignKey(
60 blank=True,
61 db_constraint=False,
62 null=True,
63 on_delete=django.db.models.deletion.DO_NOTHING,
64 related_name="+",
65 to=settings.AUTH_USER_MODEL,
66 ),
67 ),
68 (
69 "history_user",
70 models.ForeignKey(
71 null=True,
72 on_delete=django.db.models.deletion.SET_NULL,
73 related_name="+",
74 to=settings.AUTH_USER_MODEL,
75 ),
76 ),
77 (
78 "organization",
79 models.ForeignKey(
80 blank=True,
81 db_constraint=False,
82 null=True,
83 on_delete=django.db.models.deletion.DO_NOTHING,
84 related_name="+",
85 to="organization.organization",
86 ),
87 ),
88 (
89 "updated_by",
90 models.ForeignKey(
91 blank=True,
92 db_constraint=False,
93 null=True,
94 on_delete=django.db.models.deletion.DO_NOTHING,
95 related_name="+",
96 to=settings.AUTH_USER_MODEL,
97 ),
98 ),
99 ],
100 options={
101 "verbose_name": "historical team",
102 "verbose_name_plural": "historical teams",
103 "ordering": ("-history_date", "-history_id"),
104 "get_latest_by": ("history_date", "history_id"),
105 },
106 bases=(simple_history.models.HistoricalChanges, models.Model),
107 ),
108 migrations.CreateModel(
109 name="Team",
110 fields=[
111 (
112 "id",
113 models.BigAutoField(
114 auto_created=True,
115 primary_key=True,
116 serialize=False,
117 verbose_name="ID",
118 ),
119 ),
120 ("version", models.PositiveIntegerField(default=1, editable=False)),
121 ("created_at", models.DateTimeField(auto_now_add=True)),
122 ("updated_at", models.DateTimeField(auto_now=True)),
123 ("deleted_at", models.DateTimeField(blank=True, null=True)),
124 (
125 "guid",
126 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
127 ),
128 ("name", models.CharField(max_length=200)),
129 ("slug", models.SlugField(max_length=200, unique=True)),
130 ("description", models.TextField(blank=True, default="")),
131 (
132 "created_by",
133 models.ForeignKey(
134 blank=True,
135 null=True,
136 on_delete=django.db.models.deletion.SET_NULL,
137 related_name="+",
138 to=settings.AUTH_USER_MODEL,
139 ),
140 ),
141 (
142 "deleted_by",
143 models.ForeignKey(
144 blank=True,
145 null=True,
146 on_delete=django.db.models.deletion.SET_NULL,
147 related_name="+",
148 to=settings.AUTH_USER_MODEL,
149 ),
150 ),
151 (
152 "members",
153 models.ManyToManyField(blank=True, related_name="teams", to=settings.AUTH_USER_MODEL),
154 ),
155 (
156 "organization",
157 models.ForeignKey(
158 on_delete=django.db.models.deletion.CASCADE,
159 related_name="teams",
160 to="organization.organization",
161 ),
162 ),
163 (
164 "updated_by",
165 models.ForeignKey(
166 blank=True,
167 null=True,
168 on_delete=django.db.models.deletion.SET_NULL,
169 related_name="+",
170 to=settings.AUTH_USER_MODEL,
171 ),
172 ),
173 ],
174 options={
175 "ordering": ["name"],
176 },
177 ),
178 ]
--- a/organization/models.py
+++ b/organization/models.py
@@ -0,0 +1,38 @@
1
+from django.contrib.auth.models import Group
2
+from django.db import models
3
+
4
+from core.models import ActiveManager, BaseCoreModel, Tracking
5
+
6
+
7
+class Organization(BaseCoreModel):
8
+ website = models.URLField(blank=True, default="")
9
+ groups = models.ManyToManyField(Group, blank=True, related_name="organizations")
10
+
11
+ objects = ActiveManager()
12
+ all_objects = models.Manager()
13
+
14
+ class Meta:
15
+ oTeam(BaseCoreModel):
16
+ s Team(BaseCoreModel):
17
+ organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="teams")
18
+ members = models.ManyToManyField("auth.User", blank=True, related_name="teams")
19
+
20
+ objects = ActiveManager()
21
+ all_objects = models.Manager()
22
+
23
+ class Meta:
24
+ ordering = ["name"]
25
+
26
+
27
+class OrganizationMember(Tracking):
28
+ is_active = models.BooleanField(default=True)
29
+ member = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="memberships")
30
+ organization = models.ForeignKey(Organization, on_delete=models.CASCADE, rfrom django.contrib.auth.models import Group
31
+from django.db import models
32
+
33
+from core.models import ActiveManager, BaseCoreModel, Tracking
34
+
35
+
36
+class Organization(BaseCoreModel):
37
+ website = models.URLField(blank=True, default="")
38
+ groups = models.ManyToManyField(Grotrib.auth.models import Gro
--- a/organization/models.py
+++ b/organization/models.py
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/models.py
+++ b/organization/models.py
@@ -0,0 +1,38 @@
1 from django.contrib.auth.models import Group
2 from django.db import models
3
4 from core.models import ActiveManager, BaseCoreModel, Tracking
5
6
7 class Organization(BaseCoreModel):
8 website = models.URLField(blank=True, default="")
9 groups = models.ManyToManyField(Group, blank=True, related_name="organizations")
10
11 objects = ActiveManager()
12 all_objects = models.Manager()
13
14 class Meta:
15 oTeam(BaseCoreModel):
16 s Team(BaseCoreModel):
17 organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="teams")
18 members = models.ManyToManyField("auth.User", blank=True, related_name="teams")
19
20 objects = ActiveManager()
21 all_objects = models.Manager()
22
23 class Meta:
24 ordering = ["name"]
25
26
27 class OrganizationMember(Tracking):
28 is_active = models.BooleanField(default=True)
29 member = models.ForeignKey("auth.User", on_delete=models.CASCADE, related_name="memberships")
30 organization = models.ForeignKey(Organization, on_delete=models.CASCADE, rfrom django.contrib.auth.models import Group
31 from django.db import models
32
33 from core.models import ActiveManager, BaseCoreModel, Tracking
34
35
36 class Organization(BaseCoreModel):
37 website = models.URLField(blank=True, default="")
38 groups = models.ManyToManyField(Grotrib.auth.models import Gro
--- a/organization/tests.py
+++ b/organization/tests.py
@@ -0,0 +1,179 @@
1
+import pytest
2
+from django.contrib.auth.models import User
3
+
4
+from .models import Organization, OrganizationMember, Team
5
+
6
+
7
+@pytest.mark.django_db
8
+class TestOrganization:
9
+ def test_create_organization(self):
10
+ org = Organization.objects.create(name="Acme Corp")
11
+ assert org.slug == "acme-corp"
12
+ assert org.guid is not None
13
+
14
+ def test_soft_delete_excludes_from_default_manager(self):
15
+ user = User.objects.create_user(username="test", password="x")
16
+ org = Organization.objects.create(name="DeleteMe")
17
+ org.soft_delete(user=user)
18
+ assert Organization.objects.filter(slug="deleteme").count() == 0
19
+ assert Organization.all_objects.filter(slug="deleteme").count() == 1
20
+
21
+
22
+@pytest.mark.django_db
23
+class TestOrganizationMember:
24
+ def test_create_membership(self, admin_user, org):
25
+ assert OrganizationMember.objects.filter(member=admin_user, organization=org).exists()
26
+
27
+ def test_unique_membership(self, admin_user, org):
28
+ from django.db import IntegrityError
29
+
30
+ with pytest.raises(IntegrityError):
31
+ OrganizationMember.objects.create(member=admin_user, organization=org)
32
+
33
+ def test_str_representation(self, admin_user, org):
34
+ member = OrganizationMember.objects.get(member=admin_user, organization=org)
35
+ assert str(member) == f"{org}/{admin_user}"
36
+
37
+
38
+@pytest.mark.django_db
39
+class TestOrgSettingsViews:
40
+ def test_settings_page_renders(self, admin_client, org):
41
+ response = admin_client.get("/settings/")
42
+ assert response.status_code == 200
43
+ assert org.name in response.content.decode()
44
+
45
+ def test_settings_denied_without_perm(self, no_perm_client, org):
46
+ response = no_perm_client.get("/settings/")
47
+ assert response.status_code == 403
48
+
49
+ def test_settings_edit_renders(self, admin_client, org):
50
+ response = admin_client.get("/settings/edit/")
51
+ assert response.status_code == 200
52
+
53
+ def test_settings_edit_saves(self, admin_client, org):
54
+ response = admin_client.post("/settings/edit/", {"name": "Updated Org", "description": "New desc", "website": ""})
55
+ assert response.status_code == 302
56
+ org.refresh_from_db()
57
+ assert org.name == "Updated Org"
58
+
59
+ def test_settings_edit_denied(self, no_perm_client, org):
60
+ response = no_perm_client.post("/settings/edit/", {"name": "Hacked"})
61
+ assert response.status_code == 403
62
+
63
+
64
+@pytest.mark.django_db
65
+class TestMemberViews:
66
+ def test_member_list_renders(self, admin_client, org):
67
+ response = admin_client.get("/settings/members/")
68
+ assert response.status_code == 200
69
+
70
+ def test_member_list_htmx_returns_partial(self, admin_client, org):
71
+ response = admin_client.get("/settings/members/", HTTP_HX_REQUEST="true")
72
+ assert response.status_code == 200
73
+ assert b"member-table" in response.content
74
+
75
+ def test_member_list_search(self, admin_client, org):
76
+ response = admin_client.get("/settings/members/?search=admin")
77
+ assert response.status_code == 200
78
+
79
+ def test_member_list_denied(self, no_perm_client, org):
80
+ response = no_perm_client.get("/settings/members/")
81
+ assert response.status_code == 403
82
+
83
+ def test_member_add(self, admin_client, org):
84
+ User.objects.create_user(username="newuser", password="x")
85
+ response = admin_client.post("/settings/members/add/", {"user": User.objects.get(username="newuser").id})
86
+ assert response.status_code == 302
87
+ assert OrganizationMember.objects.filter(member__username="newuser", organization=org).exists()
88
+
89
+ def test_member_add_denied(self, no_perm_client, org):
90
+ response = no_perm_client.get("/settings/members/add/")
91
+ assert response.status_code == 403
92
+
93
+ def test_member_remove(self, admin_client, org, admin_user):
94
+ response = admin_client.post(f"/settings/members/{admin_user.username}/remove/")
95
+ assert response.status_code == 302
96
+ membership = OrganizationMember.all_objects.get(member=admin_user, organization=org)
97
+ assert membership.is_deleted
98
+
99
+ def test_member_remove_denied(self, no_perm_client, org, admin_user):
100
+ response = no_perm_client.post(f"/settings/members/{admin_user.username}/remove/")
101
+ assert response.status_code == 403
102
+
103
+
104
+@pytest.mark.django_db
105
+class TestTeamModel:
106
+ def test_create_team(self, org, admin_user):
107
+ team = Team.objects.create(name="Backend", organization=org, created_by=admin_user)
108
+ assert team.slug == "backend"
109
+ assert team.guid is not None
110
+
111
+ def test_soft_delete_team(self, sample_team, admin_user):
112
+ sample_team.soft_delete(user=admin_user)
113
+ assert Team.objects.filter(slug=sample_team.slug).count() == 0
114
+ assert Team.all_objects.filter(slug=sample_team.slug).count() == 1
115
+
116
+
117
+@pytest.mark.django_db
118
+class TestTeamViews:
119
+ def test_team_list_renders(self, admin_client, org, sample_team):
120
+ response = admin_client.get("/settings/teams/")
121
+ assert response.status_code == 200
122
+ assert sample_team.name in response.content.decode()
123
+
124
+ def test_team_list_htmx(self, admin_client, org, sample_team):
125
+ response = admin_client.get("/settings/teams/", HTTP_HX_REQUEST="true")
126
+ assert response.status_code == 200
127
+ assert b"team-table" in response.content
128
+
129
+ def test_team_list_search(self, admin_client, org, sample_team):
130
+ response = admin_client.get("/settings/teams/?search=Core")
131
+ assert response.status_code == 200
132
+
133
+ def test_team_list_denied(self, no_perm_client, org):
134
+ response = no_perm_client.get("/settings/teams/")
135
+ assert response.status_code == 403
136
+
137
+ def test_team_create(self, admin_client, org):
138
+ response = admin_client.post("/settings/teams/create/", {"name": "New Team", "description": "A new team"})
139
+ assert response.status_code == 302
140
+ assert Team.objects.filter(slug="new-team").exists()
141
+
142
+ def test_team_create_denied(self, no_perm_client, org):
143
+ response = no_perm_client.post("/settings/teams/create/", {"name": "Hack Team"})
144
+ assert response.status_code == 403
145
+
146
+ def test_team_detail_renders(self, admin_client, sample_team):
147
+ response = admin_client.get(f"/settings/teams/{sample_team.slug}/")
148
+ assert response.status_code == 200
149
+ assert sample_team.name in response.content.decode()
150
+
151
+ def test_team_update(self, admin_client, sample_team):
152
+ response = admin_client.post(f"/settings/teams/{sample_team.slug}/edit/", {"name": "Updated Team", "description": ""})
153
+ assert response.status_code == 302
154
+ sample_team.refresh_from_db()
155
+ assert sample_team.name == "Updated Team"
156
+
157
+ def test_team_update_denied(self, no_perm_client, sample_team):
158
+ response = no_perm_client.post(f"/settings/teams/{sample_team.slug}/edit/", {"name": "Hacked"})
159
+ assert response.status_code == 403
160
+
161
+ def test_team_delete(self, admin_client, sample_team):
162
+ response = admin_client.post(f"/settings/teams/{sample_team.slug}/delete/")
163
+ assert response.status_code == 302
164
+ assert Team.objects.filter(slug=sample_team.slug).count() == 0
165
+
166
+ def test_team_delete_denied(self, no_perm_client, sample_team):
167
+ response = no_perm_client.post(f"/settings/teams/{sample_team.slug}/delete/")
168
+ assert response.status_code == 403
169
+
170
+ def test_team_member_add(self, admin_client, sample_team):
171
+ new_user = User.objects.create_user(username="teamuser", password="x")
172
+ response = admin_client.post(f"/settings/teams/{sample_team.slug}/members/add/", {"user": new_user.id})
173
+ assert response.status_code == 302
174
+ assert sample_team.members.filter(username="teamuser").exists()
175
+
176
+ def test_team_member_remove(self, admin_client, sample_team, admin_user):
177
+ response = admin_client.post(f"/settings/teams/{sample_team.slug}/members/{admin_user.username}/remove/")
178
+ assert response.status_code == 302
179
+ assert not sample_team.members.filter(username=admin_user.username).exists()
--- a/organization/tests.py
+++ b/organization/tests.py
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/tests.py
+++ b/organization/tests.py
@@ -0,0 +1,179 @@
1 import pytest
2 from django.contrib.auth.models import User
3
4 from .models import Organization, OrganizationMember, Team
5
6
7 @pytest.mark.django_db
8 class TestOrganization:
9 def test_create_organization(self):
10 org = Organization.objects.create(name="Acme Corp")
11 assert org.slug == "acme-corp"
12 assert org.guid is not None
13
14 def test_soft_delete_excludes_from_default_manager(self):
15 user = User.objects.create_user(username="test", password="x")
16 org = Organization.objects.create(name="DeleteMe")
17 org.soft_delete(user=user)
18 assert Organization.objects.filter(slug="deleteme").count() == 0
19 assert Organization.all_objects.filter(slug="deleteme").count() == 1
20
21
22 @pytest.mark.django_db
23 class TestOrganizationMember:
24 def test_create_membership(self, admin_user, org):
25 assert OrganizationMember.objects.filter(member=admin_user, organization=org).exists()
26
27 def test_unique_membership(self, admin_user, org):
28 from django.db import IntegrityError
29
30 with pytest.raises(IntegrityError):
31 OrganizationMember.objects.create(member=admin_user, organization=org)
32
33 def test_str_representation(self, admin_user, org):
34 member = OrganizationMember.objects.get(member=admin_user, organization=org)
35 assert str(member) == f"{org}/{admin_user}"
36
37
38 @pytest.mark.django_db
39 class TestOrgSettingsViews:
40 def test_settings_page_renders(self, admin_client, org):
41 response = admin_client.get("/settings/")
42 assert response.status_code == 200
43 assert org.name in response.content.decode()
44
45 def test_settings_denied_without_perm(self, no_perm_client, org):
46 response = no_perm_client.get("/settings/")
47 assert response.status_code == 403
48
49 def test_settings_edit_renders(self, admin_client, org):
50 response = admin_client.get("/settings/edit/")
51 assert response.status_code == 200
52
53 def test_settings_edit_saves(self, admin_client, org):
54 response = admin_client.post("/settings/edit/", {"name": "Updated Org", "description": "New desc", "website": ""})
55 assert response.status_code == 302
56 org.refresh_from_db()
57 assert org.name == "Updated Org"
58
59 def test_settings_edit_denied(self, no_perm_client, org):
60 response = no_perm_client.post("/settings/edit/", {"name": "Hacked"})
61 assert response.status_code == 403
62
63
64 @pytest.mark.django_db
65 class TestMemberViews:
66 def test_member_list_renders(self, admin_client, org):
67 response = admin_client.get("/settings/members/")
68 assert response.status_code == 200
69
70 def test_member_list_htmx_returns_partial(self, admin_client, org):
71 response = admin_client.get("/settings/members/", HTTP_HX_REQUEST="true")
72 assert response.status_code == 200
73 assert b"member-table" in response.content
74
75 def test_member_list_search(self, admin_client, org):
76 response = admin_client.get("/settings/members/?search=admin")
77 assert response.status_code == 200
78
79 def test_member_list_denied(self, no_perm_client, org):
80 response = no_perm_client.get("/settings/members/")
81 assert response.status_code == 403
82
83 def test_member_add(self, admin_client, org):
84 User.objects.create_user(username="newuser", password="x")
85 response = admin_client.post("/settings/members/add/", {"user": User.objects.get(username="newuser").id})
86 assert response.status_code == 302
87 assert OrganizationMember.objects.filter(member__username="newuser", organization=org).exists()
88
89 def test_member_add_denied(self, no_perm_client, org):
90 response = no_perm_client.get("/settings/members/add/")
91 assert response.status_code == 403
92
93 def test_member_remove(self, admin_client, org, admin_user):
94 response = admin_client.post(f"/settings/members/{admin_user.username}/remove/")
95 assert response.status_code == 302
96 membership = OrganizationMember.all_objects.get(member=admin_user, organization=org)
97 assert membership.is_deleted
98
99 def test_member_remove_denied(self, no_perm_client, org, admin_user):
100 response = no_perm_client.post(f"/settings/members/{admin_user.username}/remove/")
101 assert response.status_code == 403
102
103
104 @pytest.mark.django_db
105 class TestTeamModel:
106 def test_create_team(self, org, admin_user):
107 team = Team.objects.create(name="Backend", organization=org, created_by=admin_user)
108 assert team.slug == "backend"
109 assert team.guid is not None
110
111 def test_soft_delete_team(self, sample_team, admin_user):
112 sample_team.soft_delete(user=admin_user)
113 assert Team.objects.filter(slug=sample_team.slug).count() == 0
114 assert Team.all_objects.filter(slug=sample_team.slug).count() == 1
115
116
117 @pytest.mark.django_db
118 class TestTeamViews:
119 def test_team_list_renders(self, admin_client, org, sample_team):
120 response = admin_client.get("/settings/teams/")
121 assert response.status_code == 200
122 assert sample_team.name in response.content.decode()
123
124 def test_team_list_htmx(self, admin_client, org, sample_team):
125 response = admin_client.get("/settings/teams/", HTTP_HX_REQUEST="true")
126 assert response.status_code == 200
127 assert b"team-table" in response.content
128
129 def test_team_list_search(self, admin_client, org, sample_team):
130 response = admin_client.get("/settings/teams/?search=Core")
131 assert response.status_code == 200
132
133 def test_team_list_denied(self, no_perm_client, org):
134 response = no_perm_client.get("/settings/teams/")
135 assert response.status_code == 403
136
137 def test_team_create(self, admin_client, org):
138 response = admin_client.post("/settings/teams/create/", {"name": "New Team", "description": "A new team"})
139 assert response.status_code == 302
140 assert Team.objects.filter(slug="new-team").exists()
141
142 def test_team_create_denied(self, no_perm_client, org):
143 response = no_perm_client.post("/settings/teams/create/", {"name": "Hack Team"})
144 assert response.status_code == 403
145
146 def test_team_detail_renders(self, admin_client, sample_team):
147 response = admin_client.get(f"/settings/teams/{sample_team.slug}/")
148 assert response.status_code == 200
149 assert sample_team.name in response.content.decode()
150
151 def test_team_update(self, admin_client, sample_team):
152 response = admin_client.post(f"/settings/teams/{sample_team.slug}/edit/", {"name": "Updated Team", "description": ""})
153 assert response.status_code == 302
154 sample_team.refresh_from_db()
155 assert sample_team.name == "Updated Team"
156
157 def test_team_update_denied(self, no_perm_client, sample_team):
158 response = no_perm_client.post(f"/settings/teams/{sample_team.slug}/edit/", {"name": "Hacked"})
159 assert response.status_code == 403
160
161 def test_team_delete(self, admin_client, sample_team):
162 response = admin_client.post(f"/settings/teams/{sample_team.slug}/delete/")
163 assert response.status_code == 302
164 assert Team.objects.filter(slug=sample_team.slug).count() == 0
165
166 def test_team_delete_denied(self, no_perm_client, sample_team):
167 response = no_perm_client.post(f"/settings/teams/{sample_team.slug}/delete/")
168 assert response.status_code == 403
169
170 def test_team_member_add(self, admin_client, sample_team):
171 new_user = User.objects.create_user(username="teamuser", password="x")
172 response = admin_client.post(f"/settings/teams/{sample_team.slug}/members/add/", {"user": new_user.id})
173 assert response.status_code == 302
174 assert sample_team.members.filter(username="teamuser").exists()
175
176 def test_team_member_remove(self, admin_client, sample_team, admin_user):
177 response = admin_client.post(f"/settings/teams/{sample_team.slug}/members/{admin_user.username}/remove/")
178 assert response.status_code == 302
179 assert not sample_team.members.filter(username=admin_user.username).exists()
--- a/organization/urls.py
+++ b/organization/urls.py
@@ -0,0 +1,33 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "organization"
6
+
7
+urlpatterns = [
8
+ # Organization settings
9
+ path("", views.org_settings, name="settings"),
10
+ path("edit/", views.org_settings_edit, name="settings_edit"),
11
+ # Members
12
+ path("members/", views.member_list, name="members"),
13
+ path("members/add/", views.member_add, name="member_add"),
14
+ path("members/<str:username>/remove/", views.member_remove, naport path
15
+
16
+from . import views
17
+
18
+app_name = "organization"
19
+
20
+urlpatterns = [
21
+ # Organization settings
22
+ path("", views.org_settings, name="settings"),
23
+ path("edit/", views.org_settings_edit, name="settings_edit"),
24
+ # Members
25
+ path("members/", views.member_list, name="members"),
26
+ path("members/add/", views.member_add, name="member_add"),
27
+ path("members/create/", views.user_create, name="user_create"),
28
+ path("members/<str:username>/", views.user_detail, name="user_detail"),
29
+ path("members/<str:username>/edfrom django.urls import path
30
+
31
+from . import views
32
+
33
+app
--- a/organization/urls.py
+++ b/organization/urls.py
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/urls.py
+++ b/organization/urls.py
@@ -0,0 +1,33 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "organization"
6
7 urlpatterns = [
8 # Organization settings
9 path("", views.org_settings, name="settings"),
10 path("edit/", views.org_settings_edit, name="settings_edit"),
11 # Members
12 path("members/", views.member_list, name="members"),
13 path("members/add/", views.member_add, name="member_add"),
14 path("members/<str:username>/remove/", views.member_remove, naport path
15
16 from . import views
17
18 app_name = "organization"
19
20 urlpatterns = [
21 # Organization settings
22 path("", views.org_settings, name="settings"),
23 path("edit/", views.org_settings_edit, name="settings_edit"),
24 # Members
25 path("members/", views.member_list, name="members"),
26 path("members/add/", views.member_add, name="member_add"),
27 path("members/create/", views.user_create, name="user_create"),
28 path("members/<str:username>/", views.user_detail, name="user_detail"),
29 path("members/<str:username>/edfrom django.urls import path
30
31 from . import views
32
33 app
--- a/organization/views.py
+++ b/organization/views.py
@@ -0,0 +1,176 @@
1
+from django.contrib import messages
2
+from django.contrib.auth.decorators import login_required
3
+from django.contrib.auth.models import User
4
+from django.http import HttpResponse
5
+from django.shortcuts import get_object_or_404, reermissions imMemberAddForm, OrganizationSettingsForm, TeamForm,equest, slug):
6
+
7
+from .models import OrganizTeam
8
+
9
+
10
+def get_org():
11
+ return Organization.objects.first()
12
+
13
+
14
+# --- Organization Settings ---
15
+
16
+
17
+@login_required
18
+def org_settings(request):
19
+ P.ORGANIZATION_VIEW.check(request.user)
20
+ org = get_org()
21
+ return render(request, "organization/settings.html", {"org": org})
22
+
23
+
24
+@login_required
25
+def org_settings_edit(request):
26
+ P.ORGANIZATION_CHANGE.check(request.user)
27
+ org = get_org()
28
+
29
+ if request.method == "POST":
30
+ form = OrganizationSettingsForm(request.POST, instance=org)
31
+ if form.is_valid():
32
+ org = form.save(commit=False)
33
+ org.updated_by = request.user
34
+ org.save()
35
+ messages.success(request, "Organization settings updated.")
36
+ return redirect("organization:settings")
37
+ else:
38
+ form = OrganizationSettingsForm(instance=org)
39
+
40
+ return render(request, "organization/settings_form.html", {"form": form, "org": org})
41
+
42
+
43
+# --- Members ---
44
+
45
+
46
+@login_required
47
+def member_list(request):
48
+ P.ORGANIZATION_MEMBER_VIEW.check(request.user)
49
+ org = get_org()
50
+ members = OrganizationMember.objects.filter(organization=o)
51
+
52
+ search = request.GET.get("search", "").strip()
53
+ if search:
54
+ members = members.filter(member__username__icontains=search)
55
+
56
+ g).select_related("return render(requemember_table.html", {"mem{"members": members, "})
57
+
58
+
59
+@login_required
60
+def role_edit(request, slug):
61
+ P.ORGANIZATION_CHANGE.check(request.user)
62
+ role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
63
+
64
+ if request.method == "POST":
65
+ form = OrgRoleForm(request.POST, instance=role)
66
+ if form.is_valid():
67
+ role = form.save(commit=False)
68
+ role.updated_by = request.user
69
+ role.save()
70
+ role.permissions.set(form.cleaned_data["permissions"])
71
+ messages.success(request, f'Role "{role.name}" updated.')
72
+ return redirect("organization:role_detail", slug=role.slug)
73
+ else:
74
+ form = OrgRoleForm(instance=role)
75
+
76
+ return render(request, "organization/role_form.html", {"form": form, "role": role, "title": f"Edit {role.name}"})
77
+
78
+
79
+@login_required
80
+def role_delete(request, slug):
81
+ P.ORGANIZATION_CHANGE.check(request.user)
82
+ role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
83
+ active_members = OrganizationMember.objects.filter(role=role, deleted_at__isnull=True)
84
+
85
+ if request.method == "POST":
86
+ if active_members.exists():
87
+ messages.error(
88
+ request, f'Cannot delete role "{role.name}" -- it has {active_members.count()} active member(s). Reassign them first.'
89
+ )
90
+ return redirect("organization:role_detail", slug=role.slug)
91
+
92
+ role.soft_delete(user=request.user)
93
+ messages.success(request, f'Role "{role.name}" deleted.')
94
+
95
+ if request.headers.get("HX-Request"):
96
+ return HttpResponse(status=200, headers={"HX-Redirect": "/settings/roles/"})
97
+
98
+ return redirect("organization:role_list")
99
+
100
+ return render(
101
+ request,
102
+ "organization/role_confirm_delete.html",
103
+ {"role": role, "active_members": active_members},
104
+ )
105
+
106
+
107
+@login_required
108
+def audit_log(request):
109
+ """Unified audit log across all tracked models. Requires superuser or org admin."""
110
+ from core.pagination import manual_paginate
111
+
112
+ if not request.user.is_superuser:
113
+ P.ORGANIZATION_CHANGE.check(request.user)
114
+
115
+ from fossil.models import FossilRepository
116
+ from projects.models import Project
117
+
118
+ trackable_models = [
119
+ ("Project", Project),
120
+ ("Organization", Organization),
121
+ ("Team", Team),
122
+ ("FossilRepository", FossilRepository),
123
+ ]
124
+
125
+ entries = []
126
+ model_filter = request.GET.get("model", "").strip()
127
+
128
+ for label, model in trackable_models:
129
+ if model_filter and label.lower() != model_filter.lower():
130
+ continue
131
+ history_model = model.history.model
132
+ qs = history_model.objects.all().select_related("history_user").order_by("-history_date")[:500]
133
+ for h in qs:
134
+ entries.append(
135
+ {
136
+ "date": h.history_date,
137
+ "user": h.history_user,
138
+ "action": h.get_history_type_display(),
139
+ "model": label,
140
+ "object_repr": str(h),
141
+ "object_id": h.pk,
142
+ }
143
+ )
144
+
145
+ entries.sort(key=lambda x: x["date"], reverse=True)
146
+
147
+ per_page = get_per_page(request)
148
+ entries, pagination = manual_paginate(entries, request, per_page=per_page)
149
+
150
+ available_models = [label for label, _ in trackable_models]
151
+
152
+ return render(
153
+ request,
154
+ "organization/audit_log.html",
155
+ {
156
+ "entries": entries,
157
+ "model_filter": model_filter,
158
+ "available_models": available_models,
159
+ "pagination": pagination,
160
+ "per_page": per_page,
161
+ "per_page_options": PER_PAGE_OPTIONS,
162
+ },
163
+ )
164
+
165
+
166
+@login_required
167
+def role_initialize(request):
168
+ P.ORGANIZATION_CHANGE.check(request.user)
169
+
170
+ if request.method == "POST":
171
+ from django.core.management import call_command
172
+
173
+ call_command("seed_roles")
174
+ messages.success(request, "Roles initialized successfully.")
175
+
176
+ return redirect("organization:role_list")
--- a/organization/views.py
+++ b/organization/views.py
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/organization/views.py
+++ b/organization/views.py
@@ -0,0 +1,176 @@
1 from django.contrib import messages
2 from django.contrib.auth.decorators import login_required
3 from django.contrib.auth.models import User
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, reermissions imMemberAddForm, OrganizationSettingsForm, TeamForm,equest, slug):
6
7 from .models import OrganizTeam
8
9
10 def get_org():
11 return Organization.objects.first()
12
13
14 # --- Organization Settings ---
15
16
17 @login_required
18 def org_settings(request):
19 P.ORGANIZATION_VIEW.check(request.user)
20 org = get_org()
21 return render(request, "organization/settings.html", {"org": org})
22
23
24 @login_required
25 def org_settings_edit(request):
26 P.ORGANIZATION_CHANGE.check(request.user)
27 org = get_org()
28
29 if request.method == "POST":
30 form = OrganizationSettingsForm(request.POST, instance=org)
31 if form.is_valid():
32 org = form.save(commit=False)
33 org.updated_by = request.user
34 org.save()
35 messages.success(request, "Organization settings updated.")
36 return redirect("organization:settings")
37 else:
38 form = OrganizationSettingsForm(instance=org)
39
40 return render(request, "organization/settings_form.html", {"form": form, "org": org})
41
42
43 # --- Members ---
44
45
46 @login_required
47 def member_list(request):
48 P.ORGANIZATION_MEMBER_VIEW.check(request.user)
49 org = get_org()
50 members = OrganizationMember.objects.filter(organization=o)
51
52 search = request.GET.get("search", "").strip()
53 if search:
54 members = members.filter(member__username__icontains=search)
55
56 g).select_related("return render(requemember_table.html", {"mem{"members": members, "})
57
58
59 @login_required
60 def role_edit(request, slug):
61 P.ORGANIZATION_CHANGE.check(request.user)
62 role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
63
64 if request.method == "POST":
65 form = OrgRoleForm(request.POST, instance=role)
66 if form.is_valid():
67 role = form.save(commit=False)
68 role.updated_by = request.user
69 role.save()
70 role.permissions.set(form.cleaned_data["permissions"])
71 messages.success(request, f'Role "{role.name}" updated.')
72 return redirect("organization:role_detail", slug=role.slug)
73 else:
74 form = OrgRoleForm(instance=role)
75
76 return render(request, "organization/role_form.html", {"form": form, "role": role, "title": f"Edit {role.name}"})
77
78
79 @login_required
80 def role_delete(request, slug):
81 P.ORGANIZATION_CHANGE.check(request.user)
82 role = get_object_or_404(OrgRole, slug=slug, deleted_at__isnull=True)
83 active_members = OrganizationMember.objects.filter(role=role, deleted_at__isnull=True)
84
85 if request.method == "POST":
86 if active_members.exists():
87 messages.error(
88 request, f'Cannot delete role "{role.name}" -- it has {active_members.count()} active member(s). Reassign them first.'
89 )
90 return redirect("organization:role_detail", slug=role.slug)
91
92 role.soft_delete(user=request.user)
93 messages.success(request, f'Role "{role.name}" deleted.')
94
95 if request.headers.get("HX-Request"):
96 return HttpResponse(status=200, headers={"HX-Redirect": "/settings/roles/"})
97
98 return redirect("organization:role_list")
99
100 return render(
101 request,
102 "organization/role_confirm_delete.html",
103 {"role": role, "active_members": active_members},
104 )
105
106
107 @login_required
108 def audit_log(request):
109 """Unified audit log across all tracked models. Requires superuser or org admin."""
110 from core.pagination import manual_paginate
111
112 if not request.user.is_superuser:
113 P.ORGANIZATION_CHANGE.check(request.user)
114
115 from fossil.models import FossilRepository
116 from projects.models import Project
117
118 trackable_models = [
119 ("Project", Project),
120 ("Organization", Organization),
121 ("Team", Team),
122 ("FossilRepository", FossilRepository),
123 ]
124
125 entries = []
126 model_filter = request.GET.get("model", "").strip()
127
128 for label, model in trackable_models:
129 if model_filter and label.lower() != model_filter.lower():
130 continue
131 history_model = model.history.model
132 qs = history_model.objects.all().select_related("history_user").order_by("-history_date")[:500]
133 for h in qs:
134 entries.append(
135 {
136 "date": h.history_date,
137 "user": h.history_user,
138 "action": h.get_history_type_display(),
139 "model": label,
140 "object_repr": str(h),
141 "object_id": h.pk,
142 }
143 )
144
145 entries.sort(key=lambda x: x["date"], reverse=True)
146
147 per_page = get_per_page(request)
148 entries, pagination = manual_paginate(entries, request, per_page=per_page)
149
150 available_models = [label for label, _ in trackable_models]
151
152 return render(
153 request,
154 "organization/audit_log.html",
155 {
156 "entries": entries,
157 "model_filter": model_filter,
158 "available_models": available_models,
159 "pagination": pagination,
160 "per_page": per_page,
161 "per_page_options": PER_PAGE_OPTIONS,
162 },
163 )
164
165
166 @login_required
167 def role_initialize(request):
168 P.ORGANIZATION_CHANGE.check(request.user)
169
170 if request.method == "POST":
171 from django.core.management import call_command
172
173 call_command("seed_roles")
174 messages.success(request, "Roles initialized successfully.")
175
176 return redirect("organization:role_list")

No diff available

--- a/pages/admin.py
+++ b/pages/admin.py
@@ -0,0 +1,11 @@
1
+from django.contrib import admin
2
+
3
+from core.admin import BaseCoreAdmin
4
+
5
+from .models import Page
6
+
7
+
8
+@admin.register(Page)
9
+class PageAdmin(BaseCoreAdmin):
10
+ list_display = ("name", "slug", "is_published", "created_at" "created_at", "created_by")
11
+ list_filter = ("is_publish
--- a/pages/admin.py
+++ b/pages/admin.py
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/admin.py
+++ b/pages/admin.py
@@ -0,0 +1,11 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import Page
6
7
8 @admin.register(Page)
9 class PageAdmin(BaseCoreAdmin):
10 list_display = ("name", "slug", "is_published", "created_at" "created_at", "created_by")
11 list_filter = ("is_publish
--- a/pages/apps.py
+++ b/pages/apps.py
@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class PagesConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "pages"
--- a/pages/apps.py
+++ b/pages/apps.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/pages/apps.py
+++ b/pages/apps.py
@@ -0,0 +1,6 @@
1 from django.apps import AppConfig
2
3
4 class PagesConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "pages"
--- a/pages/forms.py
+++ b/pages/forms.py
@@ -0,0 +1,16 @@
1
+from django import forms
2
+
3
+from .models import Page
4
+
5
+tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
6
+
7
+
8
+class PageForm(forms.ModelForm):
9
+ class Meta:
10
+ model = Page
11
+ fields = ["name", "content", "is_published"]
12
+ widgets = {
13
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "Page title"}),
14
+ "content": forms.Textarea(attrs={"class": tw + " font-mono", "rows": 20, "placeholder": "Write in Markdown..."}),
15
+ "is_published": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}),
16
+ }
--- a/pages/forms.py
+++ b/pages/forms.py
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/forms.py
+++ b/pages/forms.py
@@ -0,0 +1,16 @@
1 from django import forms
2
3 from .models import Page
4
5 tw = "w-full rounded-md border-gray-300 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
6
7
8 class PageForm(forms.ModelForm):
9 class Meta:
10 model = Page
11 fields = ["name", "content", "is_published"]
12 widgets = {
13 "name": forms.TextInput(attrs={"class": tw, "placeholder": "Page title"}),
14 "content": forms.Textarea(attrs={"class": tw + " font-mono", "rows": 20, "placeholder": "Write in Markdown..."}),
15 "is_published": forms.CheckboxInput(attrs={"class": "rounded border-gray-300 text-brand"}),
16 }
--- a/pages/migrations/0001_initial.py
+++ b/pages/migrations/0001_initial.py
@@ -0,0 +1,180 @@
1
+# Generated by Django 5.2.12 on 2026-04-06 01:25
2
+
3
+import uuid
4
+
5
+import django.db.models.deletion
6
+import simple_history.models
7
+from django.conf import settings
8
+from django.db import migrations, models
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ("organization", "0002_historicalteam_team"),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name="HistoricalPage",
22
+ fields=[
23
+ (
24
+ "id",
25
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26
+ ),
27
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
28
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
29
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
30
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
31
+ (
32
+ "guid",
33
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34
+ ),
35
+ ("name", models.CharField(max_length=200)),
36
+ ("slug", models.SlugField(max_length=200)),
37
+ ("description", models.TextField(blank=True, default="")),
38
+ ("content", models.TextField(blank=True, default="")),
39
+ ("is_published", models.BooleanField(default=True)),
40
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
41
+ ("history_date", models.DateTimeField(db_index=True)),
42
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
43
+ (
44
+ "history_type",
45
+ models.CharField(
46
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
47
+ max_length=1,
48
+ ),
49
+ ),
50
+ (
51
+ "created_by",
52
+ models.ForeignKey(
53
+ blank=True,
54
+ db_constraint=False,
55
+ null=True,
56
+ on_delete=django.db.models.deletion.DO_NOTHING,
57
+ related_name="+",
58
+ to=settings.AUTH_USER_MODEL,
59
+ ),
60
+ ),
61
+ (
62
+ "deleted_by",
63
+ models.ForeignKey(
64
+ blank=True,
65
+ db_constraint=False,
66
+ null=True,
67
+ on_delete=django.db.models.deletion.DO_NOTHING,
68
+ related_name="+",
69
+ to=settings.AUTH_USER_MODEL,
70
+ ),
71
+ ),
72
+ (
73
+ "history_user",
74
+ models.ForeignKey(
75
+ null=True,
76
+ on_delete=django.db.models.deletion.SET_NULL,
77
+ related_name="+",
78
+ to=settings.AUTH_USER_MODEL,
79
+ ),
80
+ ),
81
+ (
82
+ "organization",
83
+ models.ForeignKey(
84
+ blank=True,
85
+ db_constraint=False,
86
+ null=True,
87
+ on_delete=django.db.models.deletion.DO_NOTHING,
88
+ related_name="+",
89
+ to="organization.organization",
90
+ ),
91
+ ),
92
+ (
93
+ "updated_by",
94
+ models.ForeignKey(
95
+ blank=True,
96
+ db_constraint=False,
97
+ null=True,
98
+ on_delete=django.db.models.deletion.DO_NOTHING,
99
+ related_name="+",
100
+ to=settings.AUTH_USER_MODEL,
101
+ ),
102
+ ),
103
+ ],
104
+ options={
105
+ "verbose_name": "historical page",
106
+ "verbose_name_plural": "historical pages",
107
+ "ordering": ("-history_date", "-history_id"),
108
+ "get_latest_by": ("history_date", "history_id"),
109
+ },
110
+ bases=(simple_history.models.HistoricalChanges, models.Model),
111
+ ),
112
+ migrations.CreateModel(
113
+ name="Page",
114
+ fields=[
115
+ (
116
+ "id",
117
+ models.BigAutoField(
118
+ auto_created=True,
119
+ primary_key=True,
120
+ serialize=False,
121
+ verbose_name="ID",
122
+ ),
123
+ ),
124
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
125
+ ("created_at", models.DateTimeField(auto_now_add=True)),
126
+ ("updated_at", models.DateTimeField(auto_now=True)),
127
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
128
+ (
129
+ "guid",
130
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
131
+ ),
132
+ ("name", models.CharField(max_length=200)),
133
+ ("slug", models.SlugField(max_length=200, unique=True)),
134
+ ("description", models.TextField(blank=True, default="")),
135
+ ("content", models.TextField(blank=True, default="")),
136
+ ("is_published", models.BooleanField(default=True)),
137
+ (
138
+ "created_by",
139
+ models.ForeignKey(
140
+ blank=True,
141
+ null=True,
142
+ on_delete=django.db.models.deletion.SET_NULL,
143
+ related_name="+",
144
+ to=settings.AUTH_USER_MODEL,
145
+ ),
146
+ ),
147
+ (
148
+ "deleted_by",
149
+ models.ForeignKey(
150
+ blank=True,
151
+ null=True,
152
+ on_delete=django.db.models.deletion.SET_NULL,
153
+ related_name="+",
154
+ to=settings.AUTH_USER_MODEL,
155
+ ),
156
+ ),
157
+ (
158
+ "organization",
159
+ models.ForeignKey(
160
+ on_delete=django.db.models.deletion.CASCADE,
161
+ related_name="pages",
162
+ to="organization.organization",
163
+ ),
164
+ ),
165
+ (
166
+ "updated_by",
167
+ models.ForeignKey(
168
+ blank=True,
169
+ null=True,
170
+ on_delete=django.db.models.deletion.SET_NULL,
171
+ related_name="+",
172
+ to=settings.AUTH_USER_MODEL,
173
+ ),
174
+ ),
175
+ ],
176
+ options={
177
+ "ordering": ["name"],
178
+ },
179
+ ),
180
+ ]
--- a/pages/migrations/0001_initial.py
+++ b/pages/migrations/0001_initial.py
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/migrations/0001_initial.py
+++ b/pages/migrations/0001_initial.py
@@ -0,0 +1,180 @@
1 # Generated by Django 5.2.12 on 2026-04-06 01:25
2
3 import uuid
4
5 import django.db.models.deletion
6 import simple_history.models
7 from django.conf import settings
8 from django.db import migrations, models
9
10
11 class Migration(migrations.Migration):
12 initial = True
13
14 dependencies = [
15 ("organization", "0002_historicalteam_team"),
16 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17 ]
18
19 operations = [
20 migrations.CreateModel(
21 name="HistoricalPage",
22 fields=[
23 (
24 "id",
25 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26 ),
27 ("version", models.PositiveIntegerField(default=1, editable=False)),
28 ("created_at", models.DateTimeField(blank=True, editable=False)),
29 ("updated_at", models.DateTimeField(blank=True, editable=False)),
30 ("deleted_at", models.DateTimeField(blank=True, null=True)),
31 (
32 "guid",
33 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34 ),
35 ("name", models.CharField(max_length=200)),
36 ("slug", models.SlugField(max_length=200)),
37 ("description", models.TextField(blank=True, default="")),
38 ("content", models.TextField(blank=True, default="")),
39 ("is_published", models.BooleanField(default=True)),
40 ("history_id", models.AutoField(primary_key=True, serialize=False)),
41 ("history_date", models.DateTimeField(db_index=True)),
42 ("history_change_reason", models.CharField(max_length=100, null=True)),
43 (
44 "history_type",
45 models.CharField(
46 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
47 max_length=1,
48 ),
49 ),
50 (
51 "created_by",
52 models.ForeignKey(
53 blank=True,
54 db_constraint=False,
55 null=True,
56 on_delete=django.db.models.deletion.DO_NOTHING,
57 related_name="+",
58 to=settings.AUTH_USER_MODEL,
59 ),
60 ),
61 (
62 "deleted_by",
63 models.ForeignKey(
64 blank=True,
65 db_constraint=False,
66 null=True,
67 on_delete=django.db.models.deletion.DO_NOTHING,
68 related_name="+",
69 to=settings.AUTH_USER_MODEL,
70 ),
71 ),
72 (
73 "history_user",
74 models.ForeignKey(
75 null=True,
76 on_delete=django.db.models.deletion.SET_NULL,
77 related_name="+",
78 to=settings.AUTH_USER_MODEL,
79 ),
80 ),
81 (
82 "organization",
83 models.ForeignKey(
84 blank=True,
85 db_constraint=False,
86 null=True,
87 on_delete=django.db.models.deletion.DO_NOTHING,
88 related_name="+",
89 to="organization.organization",
90 ),
91 ),
92 (
93 "updated_by",
94 models.ForeignKey(
95 blank=True,
96 db_constraint=False,
97 null=True,
98 on_delete=django.db.models.deletion.DO_NOTHING,
99 related_name="+",
100 to=settings.AUTH_USER_MODEL,
101 ),
102 ),
103 ],
104 options={
105 "verbose_name": "historical page",
106 "verbose_name_plural": "historical pages",
107 "ordering": ("-history_date", "-history_id"),
108 "get_latest_by": ("history_date", "history_id"),
109 },
110 bases=(simple_history.models.HistoricalChanges, models.Model),
111 ),
112 migrations.CreateModel(
113 name="Page",
114 fields=[
115 (
116 "id",
117 models.BigAutoField(
118 auto_created=True,
119 primary_key=True,
120 serialize=False,
121 verbose_name="ID",
122 ),
123 ),
124 ("version", models.PositiveIntegerField(default=1, editable=False)),
125 ("created_at", models.DateTimeField(auto_now_add=True)),
126 ("updated_at", models.DateTimeField(auto_now=True)),
127 ("deleted_at", models.DateTimeField(blank=True, null=True)),
128 (
129 "guid",
130 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
131 ),
132 ("name", models.CharField(max_length=200)),
133 ("slug", models.SlugField(max_length=200, unique=True)),
134 ("description", models.TextField(blank=True, default="")),
135 ("content", models.TextField(blank=True, default="")),
136 ("is_published", models.BooleanField(default=True)),
137 (
138 "created_by",
139 models.ForeignKey(
140 blank=True,
141 null=True,
142 on_delete=django.db.models.deletion.SET_NULL,
143 related_name="+",
144 to=settings.AUTH_USER_MODEL,
145 ),
146 ),
147 (
148 "deleted_by",
149 models.ForeignKey(
150 blank=True,
151 null=True,
152 on_delete=django.db.models.deletion.SET_NULL,
153 related_name="+",
154 to=settings.AUTH_USER_MODEL,
155 ),
156 ),
157 (
158 "organization",
159 models.ForeignKey(
160 on_delete=django.db.models.deletion.CASCADE,
161 related_name="pages",
162 to="organization.organization",
163 ),
164 ),
165 (
166 "updated_by",
167 models.ForeignKey(
168 blank=True,
169 null=True,
170 on_delete=django.db.models.deletion.SET_NULL,
171 related_name="+",
172 to=settings.AUTH_USER_MODEL,
173 ),
174 ),
175 ],
176 options={
177 "ordering": ["name"],
178 },
179 ),
180 ]

No diff available

--- a/pages/models.py
+++ b/pages/models.py
@@ -0,0 +1,15 @@
1
+from django.db import models
2
+
3
+from core.models import ActiveManager, BaseCoreModel
4
+
5
+
6
+class Page(BaseCoreModel):
7
+ content = models.TextField(blank=True, default="")
8
+ is_published = models.BooleanField(default=True)
9
+ organization = models.ForeignKey("organization.Organization", on_delete=models.CASCADE, related_name="pages")
10
+
11
+ objects = ActiveManager()
12
+ all_objects = models.Manager()
13
+
14
+ class Meta:
15
+ ordering = ["name"]
--- a/pages/models.py
+++ b/pages/models.py
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/models.py
+++ b/pages/models.py
@@ -0,0 +1,15 @@
1 from django.db import models
2
3 from core.models import ActiveManager, BaseCoreModel
4
5
6 class Page(BaseCoreModel):
7 content = models.TextField(blank=True, default="")
8 is_published = models.BooleanField(default=True)
9 organization = models.ForeignKey("organization.Organization", on_delete=models.CASCADE, related_name="pages")
10
11 objects = ActiveManager()
12 all_objects = models.Manager()
13
14 class Meta:
15 ordering = ["name"]
--- a/pages/tests.py
+++ b/pages/tests.py
@@ -0,0 +1,53 @@
1
+import pytest
2
+
3
+from .models import Page
4
+
5
+
6
+@pytest.mark.django_db
7
+class TestPageModel:
8
+ def test_create_page(self, org, admin_user):
9
+ page = Page.objects.create(name="Test Page", content="# Hello", organization=org, created_by=admin_user)
10
+ assert page.slug == "test-page"
11
+ assert page.guid is not None
12
+ assert page.is_published is True
13
+
14
+ def test_soft_delete_page(self, sample_page, admin_user):
15
+ sample_page.soft_delete(user=admin_user)
16
+ assert Page.objects.filter(slug=sample_page.slug).count() == 0
17
+ assert Page.all_objects.filter(slug=sample_page.slug).count() == 1
18
+
19
+
20
+@pytest.mark.django_db
21
+class TestPageViews:
22
+ def test_page_list_renders(self, admin_client, sample_page):
23
+ response = admin_client.get("/kb/")
24
+ assert response.status_code == 200
25
+ assert sample_page.name in response.content.decode()
26
+
27
+ def test_page_list_htmx(self, admin_client, sample_page):
28
+ response = admin_client.get("/kb/", HTTP_HX_REQUEST="true")
29
+ assert response.status_code == 200
30
+ assert b"page-table" in response.content
31
+
32
+ def test_page_list_search(self, admin_client, sample_page):
33
+ response = admin_client.get("/kb/?search=Getting")
34
+ assert response.status_code == 200
35
+
36
+ def test_page_list_(self, admin_client, sample):
37
+ response = EW perm
38
+ response = no_perm_client.get("/kb/")
39
+ asse assert response.stcreate(self, admin_client, org):
40
+ response = admin_client.post("/kb/create/", {"name": "New Page", "content": "# New", "is_published": True})
41
+ assert response.status_code == 302
42
+ assert Page.objects.filter(slug="new-page").exists()
43
+
44
+ def test_page_create_denied(self, no_perm_client, org):
45
+ response = no_perm_client.post("/kb/create/", {"name": "Hack"})
46
+ assert response.status_code == 403
47
+
48
+ def test_page_detail_renders_markdown(self, admin_client, sample_page):
49
+ response = admin_client.get(f"/kb/{sample_page.slug}/")
50
+ assert response.status_code == 200
51
+ content = response.content.decode()
52
+ assert "<h1>" in content or "Getting Started" in content(self, admin_client, sample_page):
53
+ res
--- a/pages/tests.py
+++ b/pages/tests.py
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/tests.py
+++ b/pages/tests.py
@@ -0,0 +1,53 @@
1 import pytest
2
3 from .models import Page
4
5
6 @pytest.mark.django_db
7 class TestPageModel:
8 def test_create_page(self, org, admin_user):
9 page = Page.objects.create(name="Test Page", content="# Hello", organization=org, created_by=admin_user)
10 assert page.slug == "test-page"
11 assert page.guid is not None
12 assert page.is_published is True
13
14 def test_soft_delete_page(self, sample_page, admin_user):
15 sample_page.soft_delete(user=admin_user)
16 assert Page.objects.filter(slug=sample_page.slug).count() == 0
17 assert Page.all_objects.filter(slug=sample_page.slug).count() == 1
18
19
20 @pytest.mark.django_db
21 class TestPageViews:
22 def test_page_list_renders(self, admin_client, sample_page):
23 response = admin_client.get("/kb/")
24 assert response.status_code == 200
25 assert sample_page.name in response.content.decode()
26
27 def test_page_list_htmx(self, admin_client, sample_page):
28 response = admin_client.get("/kb/", HTTP_HX_REQUEST="true")
29 assert response.status_code == 200
30 assert b"page-table" in response.content
31
32 def test_page_list_search(self, admin_client, sample_page):
33 response = admin_client.get("/kb/?search=Getting")
34 assert response.status_code == 200
35
36 def test_page_list_(self, admin_client, sample):
37 response = EW perm
38 response = no_perm_client.get("/kb/")
39 asse assert response.stcreate(self, admin_client, org):
40 response = admin_client.post("/kb/create/", {"name": "New Page", "content": "# New", "is_published": True})
41 assert response.status_code == 302
42 assert Page.objects.filter(slug="new-page").exists()
43
44 def test_page_create_denied(self, no_perm_client, org):
45 response = no_perm_client.post("/kb/create/", {"name": "Hack"})
46 assert response.status_code == 403
47
48 def test_page_detail_renders_markdown(self, admin_client, sample_page):
49 response = admin_client.get(f"/kb/{sample_page.slug}/")
50 assert response.status_code == 200
51 content = response.content.decode()
52 assert "<h1>" in content or "Getting Started" in content(self, admin_client, sample_page):
53 res
--- a/pages/urls.py
+++ b/pages/urls.py
@@ -0,0 +1,13 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "pages"
6
+
7
+urlpatterns = [
8
+ path("", views.page_list, name="list"),
9
+ path("create/", views.page_create, name="create"),
10
+ path("<slug:slug>/", views.page_detail, name="detail"),
11
+ path("<slug:slug>/edit/", views.page_update, name="update"),
12
+ path("<slug:slug>/delete/", views.page_delete, name="delete"),
13
+]
--- a/pages/urls.py
+++ b/pages/urls.py
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/urls.py
+++ b/pages/urls.py
@@ -0,0 +1,13 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "pages"
6
7 urlpatterns = [
8 path("", views.page_list, name="list"),
9 path("create/", views.page_create, name="create"),
10 path("<slug:slug>/", views.page_detail, name="detail"),
11 path("<slug:slug>/edit/", views.page_update, name="update"),
12 path("<slug:slug>/delete/", views.page_delete, name="delete"),
13 ]
--- a/pages/views.py
+++ b/pages/views.py
@@ -0,0 +1,40 @@
1
+import markdown
2
+from django.contrib import messages
3
+from django.contrib.auth.decorators import port Paginator
4
+from django.http import HttpResponse
5
+from django.shortcuts import get_object_or_404, redirect, render
6
+from django.utils.safestring imermissions import Pnitize import sanitize_html
7
+from organization.views import get_org
8
+
9
+from .forms import PageForm
10
+ge)
11
+ page_obj = paginalist(request):
12
+ P.PAGE_VIEW
13
+
14
+ ctx = {"pages": page_obwn
15
+from django.contriimpenticated and (request.user.has_perm("pages.change_page") or request.us:
16
+ pages = Page.objects.all( from django.core.eUnpublished drafts require auth n render(request, "pages/{"pages": pages})
17
+
18
+ return rlist.html", {"pageer_page)
19
+ page_obj = paginator.get_page(request.GET.get("page", 1))
20
+
21
+ ctx = {"pages": page_obj, "page_obj": page_obj, "search": search, "per_page": per_page, "per_page_options": PER_PAGE_OPTIONS}
22
+
23
+ if request.headers.get("HX-Request"):
24
+ return render(request, "pages/partials/page_table.html", ctx)
25
+
26
+ return render(request, "pages/page_list.html", ctx)
27
+
28
+
29
+@login_required
30
+def page_create(request):
31
+ P.PAGE_ADD.check(request.user)
32
+ org = get_org()
33
+
34
+ if request.method == "POST":
35
+ form = PageForm(request.POST)
36
+ if form.is_valid():
37
+ page = form.save(commit=False)
38
+ page.organization = org
39
+ page.created_by = request.user
40
+ page.s
--- a/pages/views.py
+++ b/pages/views.py
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pages/views.py
+++ b/pages/views.py
@@ -0,0 +1,40 @@
1 import markdown
2 from django.contrib import messages
3 from django.contrib.auth.decorators import port Paginator
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, redirect, render
6 from django.utils.safestring imermissions import Pnitize import sanitize_html
7 from organization.views import get_org
8
9 from .forms import PageForm
10 ge)
11 page_obj = paginalist(request):
12 P.PAGE_VIEW
13
14 ctx = {"pages": page_obwn
15 from django.contriimpenticated and (request.user.has_perm("pages.change_page") or request.us:
16 pages = Page.objects.all( from django.core.eUnpublished drafts require auth n render(request, "pages/{"pages": pages})
17
18 return rlist.html", {"pageer_page)
19 page_obj = paginator.get_page(request.GET.get("page", 1))
20
21 ctx = {"pages": page_obj, "page_obj": page_obj, "search": search, "per_page": per_page, "per_page_options": PER_PAGE_OPTIONS}
22
23 if request.headers.get("HX-Request"):
24 return render(request, "pages/partials/page_table.html", ctx)
25
26 return render(request, "pages/page_list.html", ctx)
27
28
29 @login_required
30 def page_create(request):
31 P.PAGE_ADD.check(request.user)
32 org = get_org()
33
34 if request.method == "POST":
35 form = PageForm(request.POST)
36 if form.is_valid():
37 page = form.save(commit=False)
38 page.organization = org
39 page.created_by = request.user
40 page.s

No diff available

--- a/projects/admin.py
+++ b/projects/admin.py
@@ -0,0 +1,29 @@
1
+from django.contrib import admin
2
+
3
+from core.admin import BaseCoreAdmin
4
+
5
+from .modTeam
6
+
7
+
8
+class ProjectTeam"slug")
9
+
10
+
11
+class ProjectTeamInline(admin.TabularInline):
12
+ model = ProjectTeam
13
+ extra = 0
14
+ raw_id_fields = ("team",)
15
+
16
+
17
+@admin.register(Project)
18
+class ProjectAdmin(BaseCoreAdmin):
19
+ list_dvisibility", "organization", "created_at")
20
+ ted_at", "created_by")
21
+ lity", "gro)
22
+ inlines = [ProjectTeamInline]
23
+
24
+
25
+@admin.register(ProjectTeam)
26
+class ProjectTeamAdmin(BaseCoreAdmin):
27
+ list_display = ("project", "team", "role", "created_at")
28
+ list_filter = ("role",)
29
+ raw_id_fields = ("project", "team")
--- a/projects/admin.py
+++ b/projects/admin.py
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/admin.py
+++ b/projects/admin.py
@@ -0,0 +1,29 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .modTeam
6
7
8 class ProjectTeam"slug")
9
10
11 class ProjectTeamInline(admin.TabularInline):
12 model = ProjectTeam
13 extra = 0
14 raw_id_fields = ("team",)
15
16
17 @admin.register(Project)
18 class ProjectAdmin(BaseCoreAdmin):
19 list_dvisibility", "organization", "created_at")
20 ted_at", "created_by")
21 lity", "gro)
22 inlines = [ProjectTeamInline]
23
24
25 @admin.register(ProjectTeam)
26 class ProjectTeamAdmin(BaseCoreAdmin):
27 list_display = ("project", "team", "role", "created_at")
28 list_filter = ("role",)
29 raw_id_fields = ("project", "team")
--- a/projects/apps.py
+++ b/projects/apps.py
@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class ProjectsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "projects"
--- a/projects/apps.py
+++ b/projects/apps.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/projects/apps.py
+++ b/projects/apps.py
@@ -0,0 +1,6 @@
1 from django.apps import AppConfig
2
3
4 class ProjectsConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "projects"
--- a/projects/forms.py
+++ b/projects/forms.py
@@ -0,0 +1,35 @@
1
+ject, ProjectGroup, ProjectTeam
2
+
3
+tw = "w-full rounded-md border-gray-300 shado (optional)"})class Meta:
4
+ model = Project
5
+ fields = ["name", "description", "visibility"]
6
+ widgets = {
7
+ "name": forms.TextInput(attrs={"class": tw, "placeholder": "}L.")
8
+ return cleaned
9
+
10
+
11
+class ProjectTeamAddForm(forms.Form):
12
+ team = forms.ModelChoiceField(
13
+ queryset=Team.objects.none(),
14
+ widget=forms.Select(attrs={"class": tw}),
15
+ label="Team",
16
+ )
17
+ role = forms.ChoiceField(
18
+ choices=ProjectTeam.Role.choices,
19
+ widget=forms.Select(attrs={"class": tw}),
20
+ label="Role",
21
+ )
22
+
23
+ def __init__(self, *args, project=None, **kwargs):
24
+ super().__init__(*args, **kwargs)
25
+ if project:
26
+ assigned_team_ids = project.project_teams.filter(deleted_at__isnull=True).values_list("team_id", flat=True)
27
+ self.fields["team"].queryset = Team.objects.filter(organization=project.organization, deleted_at__isnull=True).exclude(
28
+ id__in=assigned_team_ids
29
+ )
30
+
31
+
32
+class ProjectTeamEditForm(forms.Form):
33
+ role = forms.ChoiceField(
34
+ choices=ProjectTeam.Role.choices,
35
+ widget=forms.Sel
--- a/projects/forms.py
+++ b/projects/forms.py
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/forms.py
+++ b/projects/forms.py
@@ -0,0 +1,35 @@
1 ject, ProjectGroup, ProjectTeam
2
3 tw = "w-full rounded-md border-gray-300 shado (optional)"})class Meta:
4 model = Project
5 fields = ["name", "description", "visibility"]
6 widgets = {
7 "name": forms.TextInput(attrs={"class": tw, "placeholder": "}L.")
8 return cleaned
9
10
11 class ProjectTeamAddForm(forms.Form):
12 team = forms.ModelChoiceField(
13 queryset=Team.objects.none(),
14 widget=forms.Select(attrs={"class": tw}),
15 label="Team",
16 )
17 role = forms.ChoiceField(
18 choices=ProjectTeam.Role.choices,
19 widget=forms.Select(attrs={"class": tw}),
20 label="Role",
21 )
22
23 def __init__(self, *args, project=None, **kwargs):
24 super().__init__(*args, **kwargs)
25 if project:
26 assigned_team_ids = project.project_teams.filter(deleted_at__isnull=True).values_list("team_id", flat=True)
27 self.fields["team"].queryset = Team.objects.filter(organization=project.organization, deleted_at__isnull=True).exclude(
28 id__in=assigned_team_ids
29 )
30
31
32 class ProjectTeamEditForm(forms.Form):
33 role = forms.ChoiceField(
34 choices=ProjectTeam.Role.choices,
35 widget=forms.Sel
--- a/projects/migrations/0001_initial.py
+++ b/projects/migrations/0001_initial.py
@@ -0,0 +1,395 @@
1
+# Generated by Django 5.2.12 on 2026-04-06 01:11
2
+
3
+import uuid
4
+
5
+import django.db.models.deletion
6
+import simple_history.models
7
+from django.conf import settings
8
+from django.db import migrations, models
9
+
10
+
11
+class Migration(migrations.Migration):
12
+ initial = True
13
+
14
+ dependencies = [
15
+ ("organization", "0002_historicalteam_team"),
16
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+ ]
18
+
19
+ operations = [
20
+ migrations.CreateModel(
21
+ name="HistoricalProject",
22
+ fields=[
23
+ (
24
+ "id",
25
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26
+ ),
27
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
28
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
29
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
30
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
31
+ (
32
+ "guid",
33
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34
+ ),
35
+ ("name", models.CharField(max_length=200)),
36
+ ("slug", models.SlugField(max_length=200)),
37
+ ("description", models.TextField(blank=True, default="")),
38
+ (
39
+ "visibility",
40
+ models.CharField(
41
+ choices=[
42
+ ("public", "Public"),
43
+ ("internal", "Internal"),
44
+ ("private", "Private"),
45
+ ],
46
+ default="private",
47
+ max_length=10,
48
+ ),
49
+ ),
50
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
51
+ ("history_date", models.DateTimeField(db_index=True)),
52
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
53
+ (
54
+ "history_type",
55
+ models.CharField(
56
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
57
+ max_length=1,
58
+ ),
59
+ ),
60
+ (
61
+ "created_by",
62
+ models.ForeignKey(
63
+ blank=True,
64
+ db_constraint=False,
65
+ null=True,
66
+ on_delete=django.db.models.deletion.DO_NOTHING,
67
+ related_name="+",
68
+ to=settings.AUTH_USER_MODEL,
69
+ ),
70
+ ),
71
+ (
72
+ "deleted_by",
73
+ models.ForeignKey(
74
+ blank=True,
75
+ db_constraint=False,
76
+ null=True,
77
+ on_delete=django.db.models.deletion.DO_NOTHING,
78
+ related_name="+",
79
+ to=settings.AUTH_USER_MODEL,
80
+ ),
81
+ ),
82
+ (
83
+ "history_user",
84
+ models.ForeignKey(
85
+ null=True,
86
+ on_delete=django.db.models.deletion.SET_NULL,
87
+ related_name="+",
88
+ to=settings.AUTH_USER_MODEL,
89
+ ),
90
+ ),
91
+ (
92
+ "organization",
93
+ models.ForeignKey(
94
+ blank=True,
95
+ db_constraint=False,
96
+ null=True,
97
+ on_delete=django.db.models.deletion.DO_NOTHING,
98
+ related_name="+",
99
+ to="organization.organization",
100
+ ),
101
+ ),
102
+ (
103
+ "updated_by",
104
+ models.ForeignKey(
105
+ blank=True,
106
+ db_constraint=False,
107
+ null=True,
108
+ on_delete=django.db.models.deletion.DO_NOTHING,
109
+ related_name="+",
110
+ to=settings.AUTH_USER_MODEL,
111
+ ),
112
+ ),
113
+ ],
114
+ options={
115
+ "verbose_name": "historical project",
116
+ "verbose_name_plural": "historical projects",
117
+ "ordering": ("-history_date", "-history_id"),
118
+ "get_latest_by": ("history_date", "history_id"),
119
+ },
120
+ bases=(simple_history.models.HistoricalChanges, models.Model),
121
+ ),
122
+ migrations.CreateModel(
123
+ name="Project",
124
+ fields=[
125
+ (
126
+ "id",
127
+ models.BigAutoField(
128
+ auto_created=True,
129
+ primary_key=True,
130
+ serialize=False,
131
+ verbose_name="ID",
132
+ ),
133
+ ),
134
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
135
+ ("created_at", models.DateTimeField(auto_now_add=True)),
136
+ ("updated_at", models.DateTimeField(auto_now=True)),
137
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
138
+ (
139
+ "guid",
140
+ models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
141
+ ),
142
+ ("name", models.CharField(max_length=200)),
143
+ ("slug", models.SlugField(max_length=200, unique=True)),
144
+ ("description", models.TextField(blank=True, default="")),
145
+ (
146
+ "visibility",
147
+ models.CharField(
148
+ choices=[
149
+ ("public", "Public"),
150
+ ("internal", "Internal"),
151
+ ("private", "Private"),
152
+ ],
153
+ default="private",
154
+ max_length=10,
155
+ ),
156
+ ),
157
+ (
158
+ "created_by",
159
+ models.ForeignKey(
160
+ blank=True,
161
+ null=True,
162
+ on_delete=django.db.models.deletion.SET_NULL,
163
+ related_name="+",
164
+ to=settings.AUTH_USER_MODEL,
165
+ ),
166
+ ),
167
+ (
168
+ "deleted_by",
169
+ models.ForeignKey(
170
+ blank=True,
171
+ null=True,
172
+ on_delete=django.db.models.deletion.SET_NULL,
173
+ related_name="+",
174
+ to=settings.AUTH_USER_MODEL,
175
+ ),
176
+ ),
177
+ (
178
+ "organization",
179
+ models.ForeignKey(
180
+ on_delete=django.db.models.deletion.CASCADE,
181
+ related_name="projects",
182
+ to="organization.organization",
183
+ ),
184
+ ),
185
+ (
186
+ "updated_by",
187
+ models.ForeignKey(
188
+ blank=True,
189
+ null=True,
190
+ on_delete=django.db.models.deletion.SET_NULL,
191
+ related_name="+",
192
+ to=settings.AUTH_USER_MODEL,
193
+ ),
194
+ ),
195
+ ],
196
+ options={
197
+ "ordering": ["name"],
198
+ },
199
+ ),
200
+ migrations.CreateModel(
201
+ name="HistoricalProjectTeam",
202
+ fields=[
203
+ (
204
+ "id",
205
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
206
+ ),
207
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
208
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
209
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
210
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
211
+ (
212
+ "role",
213
+ models.CharField(
214
+ choices=[
215
+ ("read", "Read"),
216
+ ("write", "Write"),
217
+ ("admin", "Admin"),
218
+ ],
219
+ default="read",
220
+ max_length=10,
221
+ ),
222
+ ),
223
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
224
+ ("history_date", models.DateTimeField(db_index=True)),
225
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
226
+ (
227
+ "history_type",
228
+ models.CharField(
229
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
230
+ max_length=1,
231
+ ),
232
+ ),
233
+ (
234
+ "created_by",
235
+ models.ForeignKey(
236
+ blank=True,
237
+ db_constraint=False,
238
+ null=True,
239
+ on_delete=django.db.models.deletion.DO_NOTHING,
240
+ related_name="+",
241
+ to=settings.AUTH_USER_MODEL,
242
+ ),
243
+ ),
244
+ (
245
+ "deleted_by",
246
+ models.ForeignKey(
247
+ blank=True,
248
+ db_constraint=False,
249
+ null=True,
250
+ on_delete=django.db.models.deletion.DO_NOTHING,
251
+ related_name="+",
252
+ to=settings.AUTH_USER_MODEL,
253
+ ),
254
+ ),
255
+ (
256
+ "history_user",
257
+ models.ForeignKey(
258
+ null=True,
259
+ on_delete=django.db.models.deletion.SET_NULL,
260
+ related_name="+",
261
+ to=settings.AUTH_USER_MODEL,
262
+ ),
263
+ ),
264
+ (
265
+ "team",
266
+ models.ForeignKey(
267
+ blank=True,
268
+ db_constraint=False,
269
+ null=True,
270
+ on_delete=django.db.models.deletion.DO_NOTHING,
271
+ related_name="+",
272
+ to="organization.team",
273
+ ),
274
+ ),
275
+ (
276
+ "updated_by",
277
+ models.ForeignKey(
278
+ blank=True,
279
+ db_constraint=False,
280
+ null=True,
281
+ on_delete=django.db.models.deletion.DO_NOTHING,
282
+ related_name="+",
283
+ to=settings.AUTH_USER_MODEL,
284
+ ),
285
+ ),
286
+ (
287
+ "project",
288
+ models.ForeignKey(
289
+ blank=True,
290
+ db_constraint=False,
291
+ null=True,
292
+ on_delete=django.db.models.deletion.DO_NOTHING,
293
+ related_name="+",
294
+ to="projects.project",
295
+ ),
296
+ ),
297
+ ],
298
+ options={
299
+ "verbose_name": "historical project team",
300
+ "verbose_name_plural": "historical project teams",
301
+ "ordering": ("-history_date", "-history_id"),
302
+ "get_latest_by": ("history_date", "history_id"),
303
+ },
304
+ bases=(simple_history.models.HistoricalChanges, models.Model),
305
+ ),
306
+ migrations.CreateModel(
307
+ name="ProjectTeam",
308
+ fields=[
309
+ (
310
+ "id",
311
+ models.BigAutoField(
312
+ auto_created=True,
313
+ primary_key=True,
314
+ serialize=False,
315
+ verbose_name="ID",
316
+ ),
317
+ ),
318
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
319
+ ("created_at", models.DateTimeField(auto_now_add=True)),
320
+ ("updated_at", models.DateTimeField(auto_now=True)),
321
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
322
+ (
323
+ "role",
324
+ models.CharField(
325
+ choices=[
326
+ ("read", "Read"),
327
+ ("write", "Write"),
328
+ ("admin", "Admin"),
329
+ ],
330
+ default="read",
331
+ max_length=10,
332
+ ),
333
+ ),
334
+ (
335
+ "created_by",
336
+ models.ForeignKey(
337
+ blank=True,
338
+ null=True,
339
+ on_delete=django.db.models.deletion.SET_NULL,
340
+ related_name="+",
341
+ to=settings.AUTH_USER_MODEL,
342
+ ),
343
+ ),
344
+ (
345
+ "deleted_by",
346
+ models.ForeignKey(
347
+ blank=True,
348
+ null=True,
349
+ on_delete=django.db.models.deletion.SET_NULL,
350
+ related_name="+",
351
+ to=settings.AUTH_USER_MODEL,
352
+ ),
353
+ ),
354
+ (
355
+ "project",
356
+ models.ForeignKey(
357
+ on_delete=django.db.models.deletion.CASCADE,
358
+ related_name="project_teams",
359
+ to="projects.project",
360
+ ),
361
+ ),
362
+ (
363
+ "team",
364
+ models.ForeignKey(
365
+ on_delete=django.db.models.deletion.CASCADE,
366
+ related_name="project_teams",
367
+ to="organization.team",
368
+ ),
369
+ ),
370
+ (
371
+ "updated_by",
372
+ models.ForeignKey(
373
+ blank=True,
374
+ null=True,
375
+ on_delete=django.db.models.deletion.SET_NULL,
376
+ related_name="+",
377
+ to=settings.AUTH_USER_MODEL,
378
+ ),
379
+ ),
380
+ ],
381
+ options={
382
+ "unique_together": {("project", "team")},
383
+ },
384
+ ),
385
+ migrations.AddField(
386
+ model_name="project",
387
+ name="teams",
388
+ field=models.ManyToManyField(
389
+ blank=True,
390
+ related_name="projects",
391
+ through="projects.ProjectTeam",
392
+ to="organization.team",
393
+ ),
394
+ ),
395
+ ]
--- a/projects/migrations/0001_initial.py
+++ b/projects/migrations/0001_initial.py
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/migrations/0001_initial.py
+++ b/projects/migrations/0001_initial.py
@@ -0,0 +1,395 @@
1 # Generated by Django 5.2.12 on 2026-04-06 01:11
2
3 import uuid
4
5 import django.db.models.deletion
6 import simple_history.models
7 from django.conf import settings
8 from django.db import migrations, models
9
10
11 class Migration(migrations.Migration):
12 initial = True
13
14 dependencies = [
15 ("organization", "0002_historicalteam_team"),
16 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17 ]
18
19 operations = [
20 migrations.CreateModel(
21 name="HistoricalProject",
22 fields=[
23 (
24 "id",
25 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
26 ),
27 ("version", models.PositiveIntegerField(default=1, editable=False)),
28 ("created_at", models.DateTimeField(blank=True, editable=False)),
29 ("updated_at", models.DateTimeField(blank=True, editable=False)),
30 ("deleted_at", models.DateTimeField(blank=True, null=True)),
31 (
32 "guid",
33 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False),
34 ),
35 ("name", models.CharField(max_length=200)),
36 ("slug", models.SlugField(max_length=200)),
37 ("description", models.TextField(blank=True, default="")),
38 (
39 "visibility",
40 models.CharField(
41 choices=[
42 ("public", "Public"),
43 ("internal", "Internal"),
44 ("private", "Private"),
45 ],
46 default="private",
47 max_length=10,
48 ),
49 ),
50 ("history_id", models.AutoField(primary_key=True, serialize=False)),
51 ("history_date", models.DateTimeField(db_index=True)),
52 ("history_change_reason", models.CharField(max_length=100, null=True)),
53 (
54 "history_type",
55 models.CharField(
56 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
57 max_length=1,
58 ),
59 ),
60 (
61 "created_by",
62 models.ForeignKey(
63 blank=True,
64 db_constraint=False,
65 null=True,
66 on_delete=django.db.models.deletion.DO_NOTHING,
67 related_name="+",
68 to=settings.AUTH_USER_MODEL,
69 ),
70 ),
71 (
72 "deleted_by",
73 models.ForeignKey(
74 blank=True,
75 db_constraint=False,
76 null=True,
77 on_delete=django.db.models.deletion.DO_NOTHING,
78 related_name="+",
79 to=settings.AUTH_USER_MODEL,
80 ),
81 ),
82 (
83 "history_user",
84 models.ForeignKey(
85 null=True,
86 on_delete=django.db.models.deletion.SET_NULL,
87 related_name="+",
88 to=settings.AUTH_USER_MODEL,
89 ),
90 ),
91 (
92 "organization",
93 models.ForeignKey(
94 blank=True,
95 db_constraint=False,
96 null=True,
97 on_delete=django.db.models.deletion.DO_NOTHING,
98 related_name="+",
99 to="organization.organization",
100 ),
101 ),
102 (
103 "updated_by",
104 models.ForeignKey(
105 blank=True,
106 db_constraint=False,
107 null=True,
108 on_delete=django.db.models.deletion.DO_NOTHING,
109 related_name="+",
110 to=settings.AUTH_USER_MODEL,
111 ),
112 ),
113 ],
114 options={
115 "verbose_name": "historical project",
116 "verbose_name_plural": "historical projects",
117 "ordering": ("-history_date", "-history_id"),
118 "get_latest_by": ("history_date", "history_id"),
119 },
120 bases=(simple_history.models.HistoricalChanges, models.Model),
121 ),
122 migrations.CreateModel(
123 name="Project",
124 fields=[
125 (
126 "id",
127 models.BigAutoField(
128 auto_created=True,
129 primary_key=True,
130 serialize=False,
131 verbose_name="ID",
132 ),
133 ),
134 ("version", models.PositiveIntegerField(default=1, editable=False)),
135 ("created_at", models.DateTimeField(auto_now_add=True)),
136 ("updated_at", models.DateTimeField(auto_now=True)),
137 ("deleted_at", models.DateTimeField(blank=True, null=True)),
138 (
139 "guid",
140 models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True),
141 ),
142 ("name", models.CharField(max_length=200)),
143 ("slug", models.SlugField(max_length=200, unique=True)),
144 ("description", models.TextField(blank=True, default="")),
145 (
146 "visibility",
147 models.CharField(
148 choices=[
149 ("public", "Public"),
150 ("internal", "Internal"),
151 ("private", "Private"),
152 ],
153 default="private",
154 max_length=10,
155 ),
156 ),
157 (
158 "created_by",
159 models.ForeignKey(
160 blank=True,
161 null=True,
162 on_delete=django.db.models.deletion.SET_NULL,
163 related_name="+",
164 to=settings.AUTH_USER_MODEL,
165 ),
166 ),
167 (
168 "deleted_by",
169 models.ForeignKey(
170 blank=True,
171 null=True,
172 on_delete=django.db.models.deletion.SET_NULL,
173 related_name="+",
174 to=settings.AUTH_USER_MODEL,
175 ),
176 ),
177 (
178 "organization",
179 models.ForeignKey(
180 on_delete=django.db.models.deletion.CASCADE,
181 related_name="projects",
182 to="organization.organization",
183 ),
184 ),
185 (
186 "updated_by",
187 models.ForeignKey(
188 blank=True,
189 null=True,
190 on_delete=django.db.models.deletion.SET_NULL,
191 related_name="+",
192 to=settings.AUTH_USER_MODEL,
193 ),
194 ),
195 ],
196 options={
197 "ordering": ["name"],
198 },
199 ),
200 migrations.CreateModel(
201 name="HistoricalProjectTeam",
202 fields=[
203 (
204 "id",
205 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
206 ),
207 ("version", models.PositiveIntegerField(default=1, editable=False)),
208 ("created_at", models.DateTimeField(blank=True, editable=False)),
209 ("updated_at", models.DateTimeField(blank=True, editable=False)),
210 ("deleted_at", models.DateTimeField(blank=True, null=True)),
211 (
212 "role",
213 models.CharField(
214 choices=[
215 ("read", "Read"),
216 ("write", "Write"),
217 ("admin", "Admin"),
218 ],
219 default="read",
220 max_length=10,
221 ),
222 ),
223 ("history_id", models.AutoField(primary_key=True, serialize=False)),
224 ("history_date", models.DateTimeField(db_index=True)),
225 ("history_change_reason", models.CharField(max_length=100, null=True)),
226 (
227 "history_type",
228 models.CharField(
229 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
230 max_length=1,
231 ),
232 ),
233 (
234 "created_by",
235 models.ForeignKey(
236 blank=True,
237 db_constraint=False,
238 null=True,
239 on_delete=django.db.models.deletion.DO_NOTHING,
240 related_name="+",
241 to=settings.AUTH_USER_MODEL,
242 ),
243 ),
244 (
245 "deleted_by",
246 models.ForeignKey(
247 blank=True,
248 db_constraint=False,
249 null=True,
250 on_delete=django.db.models.deletion.DO_NOTHING,
251 related_name="+",
252 to=settings.AUTH_USER_MODEL,
253 ),
254 ),
255 (
256 "history_user",
257 models.ForeignKey(
258 null=True,
259 on_delete=django.db.models.deletion.SET_NULL,
260 related_name="+",
261 to=settings.AUTH_USER_MODEL,
262 ),
263 ),
264 (
265 "team",
266 models.ForeignKey(
267 blank=True,
268 db_constraint=False,
269 null=True,
270 on_delete=django.db.models.deletion.DO_NOTHING,
271 related_name="+",
272 to="organization.team",
273 ),
274 ),
275 (
276 "updated_by",
277 models.ForeignKey(
278 blank=True,
279 db_constraint=False,
280 null=True,
281 on_delete=django.db.models.deletion.DO_NOTHING,
282 related_name="+",
283 to=settings.AUTH_USER_MODEL,
284 ),
285 ),
286 (
287 "project",
288 models.ForeignKey(
289 blank=True,
290 db_constraint=False,
291 null=True,
292 on_delete=django.db.models.deletion.DO_NOTHING,
293 related_name="+",
294 to="projects.project",
295 ),
296 ),
297 ],
298 options={
299 "verbose_name": "historical project team",
300 "verbose_name_plural": "historical project teams",
301 "ordering": ("-history_date", "-history_id"),
302 "get_latest_by": ("history_date", "history_id"),
303 },
304 bases=(simple_history.models.HistoricalChanges, models.Model),
305 ),
306 migrations.CreateModel(
307 name="ProjectTeam",
308 fields=[
309 (
310 "id",
311 models.BigAutoField(
312 auto_created=True,
313 primary_key=True,
314 serialize=False,
315 verbose_name="ID",
316 ),
317 ),
318 ("version", models.PositiveIntegerField(default=1, editable=False)),
319 ("created_at", models.DateTimeField(auto_now_add=True)),
320 ("updated_at", models.DateTimeField(auto_now=True)),
321 ("deleted_at", models.DateTimeField(blank=True, null=True)),
322 (
323 "role",
324 models.CharField(
325 choices=[
326 ("read", "Read"),
327 ("write", "Write"),
328 ("admin", "Admin"),
329 ],
330 default="read",
331 max_length=10,
332 ),
333 ),
334 (
335 "created_by",
336 models.ForeignKey(
337 blank=True,
338 null=True,
339 on_delete=django.db.models.deletion.SET_NULL,
340 related_name="+",
341 to=settings.AUTH_USER_MODEL,
342 ),
343 ),
344 (
345 "deleted_by",
346 models.ForeignKey(
347 blank=True,
348 null=True,
349 on_delete=django.db.models.deletion.SET_NULL,
350 related_name="+",
351 to=settings.AUTH_USER_MODEL,
352 ),
353 ),
354 (
355 "project",
356 models.ForeignKey(
357 on_delete=django.db.models.deletion.CASCADE,
358 related_name="project_teams",
359 to="projects.project",
360 ),
361 ),
362 (
363 "team",
364 models.ForeignKey(
365 on_delete=django.db.models.deletion.CASCADE,
366 related_name="project_teams",
367 to="organization.team",
368 ),
369 ),
370 (
371 "updated_by",
372 models.ForeignKey(
373 blank=True,
374 null=True,
375 on_delete=django.db.models.deletion.SET_NULL,
376 related_name="+",
377 to=settings.AUTH_USER_MODEL,
378 ),
379 ),
380 ],
381 options={
382 "unique_together": {("project", "team")},
383 },
384 ),
385 migrations.AddField(
386 model_name="project",
387 name="teams",
388 field=models.ManyToManyField(
389 blank=True,
390 related_name="projects",
391 through="projects.ProjectTeam",
392 to="organization.team",
393 ),
394 ),
395 ]

No diff available

--- a/projects/models.py
+++ b/projects/models.py
@@ -0,0 +1,39 @@
1
+from django.db import models
2
+
3
+from core.models import ActiveManager, BaseCoreModen self.name
4
+
5
+
6
+class Project(BaseCoreModel):
7
+ class Visibility(models.TextChoices):
8
+ PUBLIC = "public", "Public"
9
+ INTERNAL = "internal", "Internal"
10
+ PRIVATE = "private", "Private"
11
+
12
+ organization = models.ForeignKey("organization.Organization", olated projects",
13
+ )
14
+ visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
15
+ teams = models.ManyToManyField("organization.Team", through="ProjectTeam", blank=True, related_name="projects")
16
+
17
+ objects = ActiveManager()
18
+ all_objects = models.Manager()
19
+
20
+ class Meta:
21
+
22
+class ProjectTeam(Tracking):
23
+ class Role(models.TextChoices):
24
+ READ = "read", "Read"
25
+ WRITE = "write", "Write"
26
+ ADMIN = "admin", "Admin"
27
+
28
+ project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="project_teams")
29
+ team = models.ForeignKey("organization.Team", on_delete=models.CASCADE, related_name="project_teams")
30
+ role = models.CharField(max_length=10, choices=Role.choices, default=Role.READ)
31
+
32
+ objects = ActiveManager()
33
+ all_objects = models.Manager()
34
+
35
+ class Meta:
36
+ unique_together = ("project", "team")
37
+
38
+ def __str__(self):
39
+ return f"{self.project}/{self.team} ({self.role})"
--- a/projects/models.py
+++ b/projects/models.py
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/models.py
+++ b/projects/models.py
@@ -0,0 +1,39 @@
1 from django.db import models
2
3 from core.models import ActiveManager, BaseCoreModen self.name
4
5
6 class Project(BaseCoreModel):
7 class Visibility(models.TextChoices):
8 PUBLIC = "public", "Public"
9 INTERNAL = "internal", "Internal"
10 PRIVATE = "private", "Private"
11
12 organization = models.ForeignKey("organization.Organization", olated projects",
13 )
14 visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
15 teams = models.ManyToManyField("organization.Team", through="ProjectTeam", blank=True, related_name="projects")
16
17 objects = ActiveManager()
18 all_objects = models.Manager()
19
20 class Meta:
21
22 class ProjectTeam(Tracking):
23 class Role(models.TextChoices):
24 READ = "read", "Read"
25 WRITE = "write", "Write"
26 ADMIN = "admin", "Admin"
27
28 project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="project_teams")
29 team = models.ForeignKey("organization.Team", on_delete=models.CASCADE, related_name="project_teams")
30 role = models.CharField(max_length=10, choices=Role.choices, default=Role.READ)
31
32 objects = ActiveManager()
33 all_objects = models.Manager()
34
35 class Meta:
36 unique_together = ("project", "team")
37
38 def __str__(self):
39 return f"{self.project}/{self.team} ({self.role})"
--- a/projects/tests.py
+++ b/projects/tests.py
@@ -0,0 +1,91 @@
1
+import pytest
2
+
3
+from .models import Project, ProjectTeam
4
+
5
+
6
+@pytest.mark.django_db
7
+class TestProjectModel:
8
+ def test_create_project(self, org, admin_user):
9
+ project = Project.objects.create(name="Test Project", organization=org, created_by=admin_user)
10
+ assert project.slug == "test-project"
11
+ assert project.guid is not None
12
+ assert project.visibility == "private"
13
+
14
+ def test_soft_delete_project(self, sample_project, admin_user):
15
+ sample_project.soft_delete(user=admin_user)
16
+ assert Project.objects.filter(slug=sample_project.slug).count() == 0
17
+ assert Project.all_objects.filter(slug=sample_project.slug).count() == 1
18
+
19
+
20
+@pytest.mark.django_db
21
+class TestProjectViews:
22
+ def test_project_list_renders(self, admin_client, sample_project):
23
+ response = admin_client.get("/projects/")
24
+ assert response.status_code == 200
25
+ assert sample_project.name in response.content.decode()
26
+
27
+ def test_project_list_htmx(self, admin_client, sample_project):
28
+ response = admin_client.get("/projects/", HTTP_HX_REQUEST="true")
29
+ assert response.status_code == 200
30
+ assert b"project-table" in response.content
31
+
32
+ def test_project_list_search(self, admin_client, sample_project):
33
+ response = admin_client.get("/projects/?search=Frontend")
34
+ assert response.status_code == 200
35
+f test_project_team_remove_import pytrt Project, ProjectTeam
36
+
37
+
38
+@pyt403
39
+
40
+ def test_project_create(self, admin_client, orgimport pytest
41
+
42
+from .mrt Project, Projecct.slug).count() == 1
43
+
44
+
45
+@pytestojects/create/", {"name": "New Project", "description": "Test", "visibility": "private"})
46
+ assert response.status_code == 302
47
+ assert Project.objects.filter(slug="new-project").exists()
48
+
49
+ def test_project_create_denied(self, no_perm_client, org):
50
+ response = no_perm_client.post("/projects/create/", {"name": "Hack"})
51
+ assert response.status_code == 403
52
+
53
+ def test_project_detail_renders(self, admin_client, sample_project):
54
+ response = admin_client.get(f"/projects/{sample_project.slug}/")
55
+ assert response.status_code == 200
56
+ assert sample_project.name in response.content.decode()
57
+
58
+ def test_project_detail_shows_teams(self, admin_client, sample_project, sample_team):
59
+ response = admin_client.get(f"/projects/{sample_project.slug}/")
60
+ assert sample_team.name in response.content.decode()
61
+
62
+ def test_project_update(self, admin_client, sample_project):
63
+ response = admin_client.post(
64
+ f"/projects/{sample_project.slug}/edit/",
65
+ {"name": "Updated Project", "description": "Updated", "visibility": "public"},
66
+ )
67
+ assert response.status_code == 302
68
+ sample_project.refresh_from_db()
69
+ assert sample_project.name == "Updated Project"
70
+ assert sample_project.visibility == "public"
71
+
72
+ def test_project_update_denied(self, no_perm_client, sample_project):
73
+ response = no_perm_client.post(f"/projects/{sample_project.slug}/edit/", {"name": "Hacked"})
74
+ assert response.status_code == 403
75
+
76
+ def test_project_delete(self, admin_client, sample_project):
77
+ response = admin_client.post(f"/projects/{sample_project.slug}/delete/")
78
+ assert response.status_code == 302
79
+ assert Project.objects.filter(slug=sample_project.slug).count() == 0
80
+
81
+ def test_project_delete_denied(self, no_perm_client, sample_project):
82
+ response = no_perm_client.post(f"/projects/{sample_project.slug}/delete/")
83
+ assert response.status_code == 403
84
+
85
+
86
+@pytest.mark.django_db
87
+class TestProjectTeamViews:
88
+ def test_project_team_add(self, admin_client, org, sample_project, admin_user):
89
+ from organization.models import Team
90
+
91
+ new_team = Team.objects.create(name="QA Team", organizatio
--- a/projects/tests.py
+++ b/projects/tests.py
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/tests.py
+++ b/projects/tests.py
@@ -0,0 +1,91 @@
1 import pytest
2
3 from .models import Project, ProjectTeam
4
5
6 @pytest.mark.django_db
7 class TestProjectModel:
8 def test_create_project(self, org, admin_user):
9 project = Project.objects.create(name="Test Project", organization=org, created_by=admin_user)
10 assert project.slug == "test-project"
11 assert project.guid is not None
12 assert project.visibility == "private"
13
14 def test_soft_delete_project(self, sample_project, admin_user):
15 sample_project.soft_delete(user=admin_user)
16 assert Project.objects.filter(slug=sample_project.slug).count() == 0
17 assert Project.all_objects.filter(slug=sample_project.slug).count() == 1
18
19
20 @pytest.mark.django_db
21 class TestProjectViews:
22 def test_project_list_renders(self, admin_client, sample_project):
23 response = admin_client.get("/projects/")
24 assert response.status_code == 200
25 assert sample_project.name in response.content.decode()
26
27 def test_project_list_htmx(self, admin_client, sample_project):
28 response = admin_client.get("/projects/", HTTP_HX_REQUEST="true")
29 assert response.status_code == 200
30 assert b"project-table" in response.content
31
32 def test_project_list_search(self, admin_client, sample_project):
33 response = admin_client.get("/projects/?search=Frontend")
34 assert response.status_code == 200
35 f test_project_team_remove_import pytrt Project, ProjectTeam
36
37
38 @pyt403
39
40 def test_project_create(self, admin_client, orgimport pytest
41
42 from .mrt Project, Projecct.slug).count() == 1
43
44
45 @pytestojects/create/", {"name": "New Project", "description": "Test", "visibility": "private"})
46 assert response.status_code == 302
47 assert Project.objects.filter(slug="new-project").exists()
48
49 def test_project_create_denied(self, no_perm_client, org):
50 response = no_perm_client.post("/projects/create/", {"name": "Hack"})
51 assert response.status_code == 403
52
53 def test_project_detail_renders(self, admin_client, sample_project):
54 response = admin_client.get(f"/projects/{sample_project.slug}/")
55 assert response.status_code == 200
56 assert sample_project.name in response.content.decode()
57
58 def test_project_detail_shows_teams(self, admin_client, sample_project, sample_team):
59 response = admin_client.get(f"/projects/{sample_project.slug}/")
60 assert sample_team.name in response.content.decode()
61
62 def test_project_update(self, admin_client, sample_project):
63 response = admin_client.post(
64 f"/projects/{sample_project.slug}/edit/",
65 {"name": "Updated Project", "description": "Updated", "visibility": "public"},
66 )
67 assert response.status_code == 302
68 sample_project.refresh_from_db()
69 assert sample_project.name == "Updated Project"
70 assert sample_project.visibility == "public"
71
72 def test_project_update_denied(self, no_perm_client, sample_project):
73 response = no_perm_client.post(f"/projects/{sample_project.slug}/edit/", {"name": "Hacked"})
74 assert response.status_code == 403
75
76 def test_project_delete(self, admin_client, sample_project):
77 response = admin_client.post(f"/projects/{sample_project.slug}/delete/")
78 assert response.status_code == 302
79 assert Project.objects.filter(slug=sample_project.slug).count() == 0
80
81 def test_project_delete_denied(self, no_perm_client, sample_project):
82 response = no_perm_client.post(f"/projects/{sample_project.slug}/delete/")
83 assert response.status_code == 403
84
85
86 @pytest.mark.django_db
87 class TestProjectTeamViews:
88 def test_project_team_add(self, admin_client, org, sample_project, admin_user):
89 from organization.models import Team
90
91 new_team = Team.objects.create(name="QA Team", organizatio
--- a/projects/urls.py
+++ b/projects/urls.py
@@ -0,0 +1,16 @@
1
+from django.urls import path
2
+
3
+from . import views
4
+
5
+app_name = "projects"
6
+
7
+urlpatterns = [
8
+ path("", views.project_list, name="list"),
9
+ path("create/", views.project_create, name="create"),
10
+ , name="toggle_star"),
11
+ path("<slug:slug>/", views.project_detail, name="detail"),
12
+ path("<slug:slug>/edit/", views.project_update, name="update"),
13
+ path("<slug:slug>/delete/", views.project_delete, name="delete"),
14
+ path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"),
15
+ path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"),
16
+ path("<slug:slug>/teams/<slug:team_slug>/remove/", views.project_team_remo
--- a/projects/urls.py
+++ b/projects/urls.py
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/urls.py
+++ b/projects/urls.py
@@ -0,0 +1,16 @@
1 from django.urls import path
2
3 from . import views
4
5 app_name = "projects"
6
7 urlpatterns = [
8 path("", views.project_list, name="list"),
9 path("create/", views.project_create, name="create"),
10 , name="toggle_star"),
11 path("<slug:slug>/", views.project_detail, name="detail"),
12 path("<slug:slug>/edit/", views.project_update, name="update"),
13 path("<slug:slug>/delete/", views.project_delete, name="delete"),
14 path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"),
15 path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"),
16 path("<slug:slug>/teams/<slug:team_slug>/remove/", views.project_team_remo
--- a/projects/views.py
+++ b/projects/views.py
@@ -0,0 +1,88 @@
1
+from django.contrib import messages
2
+from django.contrib.auth.decorators import s import Count
3
+from django.http import HttpResponse
4
+from django.shortcuts iermissions import P
5
+from organization.models import Team
6
+from organization.views import get_org
7
+
8
+from .forms import ProjectForm, Project, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm
9
+from .modTeam
10
+
11
+
12
+@login_required
13
+def project_list(request):
14
+ P.PROJECTequest"):
15
+ return H return redirect("projectsall( if request.headers.get("HX-Request"):
16
+ return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"}if request.headers.itial={"role": project_team.role}):
17
+ project = form.{": projects}t, "projects/project_list.hred
18
+def project_create(request):
19
+ P.PROJECT_ADD.check(request.user)
20
+ org = get_org()
21
+
22
+ if request.method == "POS_source = form.cleaned_data.get("repo_source", "empty")
23
+ clone_url = form.cleaned_data.get("clone_url", "").strip()
24
+
25
+ if repo_source == "fossil_url" and clone_url:
26
+ from core.url_validation import is_safe_outbound_url
27
+
28
+ is_safe, url_error = is_safe_outbound_url(clone_url)
29
+ if not is_safe:
30
+ messages.e f"Invalid clone URL: {url_error}")
31
+ else:
32
+ _clone_fossil_repo(request, project, clone_url)
33
+
34
+ messages.success(request, f'Project "{project.name}" created.')
35
+ return redirect("projects:detail", slug=project.slug)
36
+ else:
37
+ form = ProjectForm()
38
+
39
+ return render(request, "projects/project_form.html", {"form": form, "title": "New Project"})
40
+
41
+
42
+def _clone_fossil_repo(request, project, clone_url):
43
+ """Clone a Fossil repo from a remote URL, replacing the empty file created by the signal."""
44
+ import subprocess
45
+
46
+ from fossil.cli import FossilCLI
47
+ from fossil.models import FossilRepository
48
+
49
+ fossil_repo = FossilRepository.objects.filter(project=project).first()
50
+ if not fossil_repo:
51
+ return
52
+
53
+ cli = FossilCLI()
54
+ if not cli.is_available():
55
+ messages.warning(request, "Fossil binary not available -- clone skipped.")
56
+ return
57
+
58
+ # Remove the empty file created by the signal so we can clone into that path
59
+ if fossil_repo.full_path.exists():
60
+ fossil_repo.full_path.unlink()
61
+
62
+ try:
63
+ result = subprocess.run(
64
+ [cli.binary, "clone", clone_url, str(fossil_repo.full_path)],
65
+ capture_output=True,
66
+ text=True,
67
+ timeout=120,
68
+ env=cli._env,
69
+ )
70
+ if result.returncode == 0:
71
+ fossil_repo.remote_url = clone_url
72
+ fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size if fossil_repo.exists_on_disk else 0
73
+ fossil_repo.save(update_fields=["remote_url", "file_size_bytes", "updated_at", "version"])
74
+ messages.success(request, f"Repository cloned from {clone_url}")
75
+ else:
76
+ messages.warning(request, f"Clone failed: {result.stderr.strip()}")
77
+ except subprocess.TimeoutExpired:
78
+ messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.")
79
+
80
+
81
+def project_detail(request, slug):
82
+ from projects.access import require_project_read
83
+
84
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
85
+ require_project_read(request, project)
86
+ project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
87
+
88
+
--- a/projects/views.py
+++ b/projects/views.py
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/views.py
+++ b/projects/views.py
@@ -0,0 +1,88 @@
1 from django.contrib import messages
2 from django.contrib.auth.decorators import s import Count
3 from django.http import HttpResponse
4 from django.shortcuts iermissions import P
5 from organization.models import Team
6 from organization.views import get_org
7
8 from .forms import ProjectForm, Project, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm
9 from .modTeam
10
11
12 @login_required
13 def project_list(request):
14 P.PROJECTequest"):
15 return H return redirect("projectsall( if request.headers.get("HX-Request"):
16 return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"}if request.headers.itial={"role": project_team.role}):
17 project = form.{": projects}t, "projects/project_list.hred
18 def project_create(request):
19 P.PROJECT_ADD.check(request.user)
20 org = get_org()
21
22 if request.method == "POS_source = form.cleaned_data.get("repo_source", "empty")
23 clone_url = form.cleaned_data.get("clone_url", "").strip()
24
25 if repo_source == "fossil_url" and clone_url:
26 from core.url_validation import is_safe_outbound_url
27
28 is_safe, url_error = is_safe_outbound_url(clone_url)
29 if not is_safe:
30 messages.e f"Invalid clone URL: {url_error}")
31 else:
32 _clone_fossil_repo(request, project, clone_url)
33
34 messages.success(request, f'Project "{project.name}" created.')
35 return redirect("projects:detail", slug=project.slug)
36 else:
37 form = ProjectForm()
38
39 return render(request, "projects/project_form.html", {"form": form, "title": "New Project"})
40
41
42 def _clone_fossil_repo(request, project, clone_url):
43 """Clone a Fossil repo from a remote URL, replacing the empty file created by the signal."""
44 import subprocess
45
46 from fossil.cli import FossilCLI
47 from fossil.models import FossilRepository
48
49 fossil_repo = FossilRepository.objects.filter(project=project).first()
50 if not fossil_repo:
51 return
52
53 cli = FossilCLI()
54 if not cli.is_available():
55 messages.warning(request, "Fossil binary not available -- clone skipped.")
56 return
57
58 # Remove the empty file created by the signal so we can clone into that path
59 if fossil_repo.full_path.exists():
60 fossil_repo.full_path.unlink()
61
62 try:
63 result = subprocess.run(
64 [cli.binary, "clone", clone_url, str(fossil_repo.full_path)],
65 capture_output=True,
66 text=True,
67 timeout=120,
68 env=cli._env,
69 )
70 if result.returncode == 0:
71 fossil_repo.remote_url = clone_url
72 fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size if fossil_repo.exists_on_disk else 0
73 fossil_repo.save(update_fields=["remote_url", "file_size_bytes", "updated_at", "version"])
74 messages.success(request, f"Repository cloned from {clone_url}")
75 else:
76 messages.warning(request, f"Clone failed: {result.stderr.strip()}")
77 except subprocess.TimeoutExpired:
78 messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.")
79
80
81 def project_detail(request, slug):
82 from projects.access import require_project_read
83
84 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
85 require_project_read(request, project)
86 project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
87
88
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -0,0 +1,71 @@
1
+[project]
2
+name =1.0"
3
+description = "Omnielf-hosted Fossil SCM forge — code hosting, issues, wiki, and continuous backups in one command."
4
+license = "MIT"
5
+requires-python = ">=3.12"
6
+readme = "README.md"
7
+authors = [
8
+ { name = "CONFLICT LLC", email = "[email protected]" },
9
+]
10
+keywords = ["fossil", "scm", "vcs", "code-hosting", "self-hosted", "forge"]
11
+classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Web Environment",
14
+ "Framework :: Django",
15
+ "Framework :: Django :: 5.1",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language pment :: Version Control",
21
+]
22
+dependencies = [
23
+ "django>=5.1,<6.0",
24
+ "psycopg2-binary>=2.9",
25
+ "redis>=5.0",
26
+ "celery[redis]>=5.4",
27
+ "django-celery-results>=2.5",
28
+ "django-celery-beat>=2.7",
29
+ "django-import-export>=4.0",
30
+ "django-simple-history>=3.7",
31
+ "django-ratelimit>=4.1",
32
+ "django-health-check>=3.18",
33
+ "django-constance[database]>=4.1",
34
+ "django-storages[s3]>=1.14",
35
+ "django-ses>=4.1",
36
+ "django-cors-headers>=4.4",
37
+ "gunicorn>=23.0",
38
+ "whitenoise>=6.7",
39
+ "boto3>=1.35",
40
+ "sentry-sdk[django]>=2.14",
41
+ "click>=8.1",
42
+ "rich>=13.0",
43
+ "markdown>=3.6",
44
+ "requests>=2.31",
45
+ "cryptography>=43.0",
46
+ "mcp>=1.0",
47
+]
48
+
49
+[project.urls]
50
+Homepage = "https://fossilrepo.dev"
51
+Documentation = "https://fossilrepo.dev"
52
+Repository = "https://github.com/ConflictHQ/fossilrepo"
53
+Issues = "https://github.com/ConflictHQ/fossilrepo/issues"
54
+Demo = "https://fossilrepo.io"
55
+
56
+[project.scripts]
57
+fossilrepo-ctl = "ctl.main:cli"
58
+fossilrepo-mcp = "mcp_server.__main__:run"
59
+
60
+[project.optional-dependencies]
61
+dev = [
62
+ "ruff>=0.7",
63
+ "forge."
64
+license = "MIT"
65
+ent :: Version Control",
66
+]
67
+dependencies = [
68
+ "django>=5.1,<6.0",
69
+ "psycopg2-binary>=2.9",
70
+ "redis>=5.0",
71
+ "c
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -0,0 +1,71 @@
1 [project]
2 name =1.0"
3 description = "Omnielf-hosted Fossil SCM forge — code hosting, issues, wiki, and continuous backups in one command."
4 license = "MIT"
5 requires-python = ">=3.12"
6 readme = "README.md"
7 authors = [
8 { name = "CONFLICT LLC", email = "[email protected]" },
9 ]
10 keywords = ["fossil", "scm", "vcs", "code-hosting", "self-hosted", "forge"]
11 classifiers = [
12 "Development Status :: 3 - Alpha",
13 "Environment :: Web Environment",
14 "Framework :: Django",
15 "Framework :: Django :: 5.1",
16 "Intended Audience :: Developers",
17 "Intended Audience :: System Administrators",
18 "License :: OSI Approved :: MIT License",
19 "Operating System :: POSIX :: Linux",
20 "Programming Language pment :: Version Control",
21 ]
22 dependencies = [
23 "django>=5.1,<6.0",
24 "psycopg2-binary>=2.9",
25 "redis>=5.0",
26 "celery[redis]>=5.4",
27 "django-celery-results>=2.5",
28 "django-celery-beat>=2.7",
29 "django-import-export>=4.0",
30 "django-simple-history>=3.7",
31 "django-ratelimit>=4.1",
32 "django-health-check>=3.18",
33 "django-constance[database]>=4.1",
34 "django-storages[s3]>=1.14",
35 "django-ses>=4.1",
36 "django-cors-headers>=4.4",
37 "gunicorn>=23.0",
38 "whitenoise>=6.7",
39 "boto3>=1.35",
40 "sentry-sdk[django]>=2.14",
41 "click>=8.1",
42 "rich>=13.0",
43 "markdown>=3.6",
44 "requests>=2.31",
45 "cryptography>=43.0",
46 "mcp>=1.0",
47 ]
48
49 [project.urls]
50 Homepage = "https://fossilrepo.dev"
51 Documentation = "https://fossilrepo.dev"
52 Repository = "https://github.com/ConflictHQ/fossilrepo"
53 Issues = "https://github.com/ConflictHQ/fossilrepo/issues"
54 Demo = "https://fossilrepo.io"
55
56 [project.scripts]
57 fossilrepo-ctl = "ctl.main:cli"
58 fossilrepo-mcp = "mcp_server.__main__:run"
59
60 [project.optional-dependencies]
61 dev = [
62 "ruff>=0.7",
63 "forge."
64 license = "MIT"
65 ent :: Version Control",
66 ]
67 dependencies = [
68 "django>=5.1,<6.0",
69 "psycopg2-binary>=2.9",
70 "redis>=5.0",
71 "c
A run.sh
+78
--- a/run.sh
+++ b/run.sh
@@ -0,0 +1,78 @@
1
+#!/usr/bin/env bash
2
+set -euo pipefail
3
+
4
+# Fossilrepo — Django HTMX
5
+# Usage: ./run.sh [command]
6
+
7
+COMPOSE_FILE=""
8
+
9
+if [ -f "docker-compose.yml" ]; then
10
+ COMPOSE_FILE="docker-compose.yml"
11
+elif [ -f "docker-compose.yaml" ]; then
12
+ COMPOSE_FILE="docker-compose.yaml"
13
+fi
14
+
15
+compose() {
16
+ if [ -n "$COMPOSE_FILE" ]; then
17
+ docker compose -f "$COMPOSE_FILE" "$@"
18
+ else
19
+ echo "No docker-compose file found"
20
+ exit 1
21
+ fi
22
+}
23
+
24
+case "${1:-help}" in
25
+ up|start)
26
+ compose up -d --build
27
+ echo "Waiting for services..."
28
+ sleep 5
29
+ compose exec -T backend python manage.py migrate --noinput 2>&1 | tail -3
30
+ echo ""
31
+ echo "Services running. Check status with: ./run.sh status"
32
+ ;;
33
+ down|stop)
34
+ compose down
35
+ ;;
36
+ restart)
37
+ compose down
38
+ compose up -d --build
39
+ ;;
40
+ status|ps)
41
+ compose ps
42
+ ;;
43
+ logs)
44
+ compose logs -f "${2:-}"
45
+ ;;
46
+ seed)
47
+ compose exec -T backend python manage.py migrate --noinput 2>&1 | tail -3
48
+ compose exec -T backend python manage.py seed
49
+ ;;
50
+ test)
51
+ compose exec backend python -m pytest --cov --cov-report=term-missing -v
52
+ ;;
53
+ lint)
54
+ compose exec backend python -m ruff check . && compose exec backend python -m ruff format --check .
55
+ ;;
56
+ shell)
57
+ compose exec backend bash
58
+ ;;
59
+ migrate)
60
+ compose exec backend python manage.py migrate
61
+ ;;
62
+ help|*)
63
+ echo "Usage: ./run.sh <command>"
64
+ echo ""
65
+ echo "Commands:"
66
+ echo " up, start Start all services"
67
+ echo " down, stop Stop all services"
68
+ echo " restart Restart all services"
69
+ echo " status, ps Show service status"
70
+ echo " logs [svc] Tail logs (optionally for one service)"
71
+ echo " seed Seed the database"
72
+ echo " test Run tests"
73
+ echo " lint Run linters"
74
+ echo " shell Open a shell in the backend"
75
+ echo " migrate Run database migrations"
76
+ echo " help Show this help"
77
+ ;;
78
+esac
--- a/run.sh
+++ b/run.sh
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/run.sh
+++ b/run.sh
@@ -0,0 +1,78 @@
1 #!/usr/bin/env bash
2 set -euo pipefail
3
4 # Fossilrepo — Django HTMX
5 # Usage: ./run.sh [command]
6
7 COMPOSE_FILE=""
8
9 if [ -f "docker-compose.yml" ]; then
10 COMPOSE_FILE="docker-compose.yml"
11 elif [ -f "docker-compose.yaml" ]; then
12 COMPOSE_FILE="docker-compose.yaml"
13 fi
14
15 compose() {
16 if [ -n "$COMPOSE_FILE" ]; then
17 docker compose -f "$COMPOSE_FILE" "$@"
18 else
19 echo "No docker-compose file found"
20 exit 1
21 fi
22 }
23
24 case "${1:-help}" in
25 up|start)
26 compose up -d --build
27 echo "Waiting for services..."
28 sleep 5
29 compose exec -T backend python manage.py migrate --noinput 2>&1 | tail -3
30 echo ""
31 echo "Services running. Check status with: ./run.sh status"
32 ;;
33 down|stop)
34 compose down
35 ;;
36 restart)
37 compose down
38 compose up -d --build
39 ;;
40 status|ps)
41 compose ps
42 ;;
43 logs)
44 compose logs -f "${2:-}"
45 ;;
46 seed)
47 compose exec -T backend python manage.py migrate --noinput 2>&1 | tail -3
48 compose exec -T backend python manage.py seed
49 ;;
50 test)
51 compose exec backend python -m pytest --cov --cov-report=term-missing -v
52 ;;
53 lint)
54 compose exec backend python -m ruff check . && compose exec backend python -m ruff format --check .
55 ;;
56 shell)
57 compose exec backend bash
58 ;;
59 migrate)
60 compose exec backend python manage.py migrate
61 ;;
62 help|*)
63 echo "Usage: ./run.sh <command>"
64 echo ""
65 echo "Commands:"
66 echo " up, start Start all services"
67 echo " down, stop Stop all services"
68 echo " restart Restart all services"
69 echo " status, ps Show service status"
70 echo " logs [svc] Tail logs (optionally for one service)"
71 echo " seed Seed the database"
72 echo " test Run tests"
73 echo " lint Run linters"
74 echo " shell Open a shell in the backend"
75 echo " migrate Run database migrations"
76 echo " help Show this help"
77 ;;
78 esac
+19
--- a/startup.py
+++ b/startup.py
@@ -0,0 +1,19 @@
1
+"""Docker container startup script. Runs migrations and starts the dev server."""
2
+
3
+import os
4
+import subprocess
5
+import sys
6
+
7
+
8
+def main():
9
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
10
+
11
+ print("Running migrations...")
12
+ subprocess.run([sys.executable, "manage.py", "migrate", "--noinput"], check=True)
13
+
14
+ print("Starting development server...")
15
+ subprocess.run([sys.executable, "manage.py", "runserver", "0.0.0.0:8000"], check=False)
16
+
17
+
18
+if __name__ == "__main__":
19
+ main()
--- a/startup.py
+++ b/startup.py
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/startup.py
+++ b/startup.py
@@ -0,0 +1,19 @@
1 """Docker container startup script. Runs migrations and starts the dev server."""
2
3 import os
4 import subprocess
5 import sys
6
7
8 def main():
9 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
10
11 print("Running migrations...")
12 subprocess.run([sys.executable, "manage.py", "migrate", "--noinput"], check=True)
13
14 print("Starting development server...")
15 subprocess.run([sys.executable, "manage.py", "runserver", "0.0.0.0:8000"], check=False)
16
17
18 if __name__ == "__main__":
19 main()
--- a/static/admin/css/dark_theme.css
+++ b/static/admin/css/dark_theme.css
@@ -0,0 +1,769 @@
1
+/*
2
+ * Fossilrepo Admin Theme — supports dark, light, and system-auto modes
3
+ * Brand palette:
4
+ * #2B2D2C charcoal (dark body background)
5
+ * #DC394C red (primary brand accent)
6
+ * #8B3138 crimson (secondary / hover states)
7
+ * #FAFAFA near-white (foreground on dark)
8
+ */
9
+
10
+/* ── Shared brand accents + dark defaults ───────────────────────────────── */
11
+
12
+:root {
13
+ /* Brand accents — unchanged across all themes */
14
+ --primary: #DC394C;
15
+ --secondary: #8B3138;
16
+ --accent: #DC394C;
17
+ --primary-fg: #FAFAFA;
18
+
19
+ --button-fg: #FAFAFA;
20
+ --button-bg: #DC394C;
21
+ --button-hover-bg: #c42d3f;
22
+ --default-button-bg: #8B3138;
23
+ --default-button-hover-bg:#6a1921;
24
+ --close-button-bg: #4a4c4b;
25
+ --close-button-hover-bg: #5a5c5b;
26
+ --delete-button-bg: #8B3138;
27
+ --delete-button-hover-bg: #6a1921;
28
+
29
+ --object-tools-fg: #FAFAFA;
30
+ --object-tools-bg: #8B3138;
31
+ --object-tools-hover-bg: #6a1921;
32
+
33
+ --breadcrumbs-bg: #DC394C;
34
+ --breadcrumbs-fg: #FAFAFA;
35
+ --breadcrumbs-link-fg: #FAFAFA;
36
+ --link-selected-fg: #DC394C;
37
+
38
+ /* Header stays dark in both themes for brand consistency */
39
+ --header-color: #FAFAFA;
40
+ --header-branding-color: #FAFAFA;
41
+ --header-bg: #1f2120;
42
+ --header-link-color: #FAFAFA;
43
+
44
+ /* Dark theme defaults (applied when no explicit data-theme is set) */
45
+ --body-fg: #FAFAFA;
46
+ --body-bg: #2B2D2C;
47
+ --body-quiet-color: #a8aaa9;
48
+ --body-loud-color: #ffffff;
49
+
50
+ --link-fg: #e8677a;
51
+ --link-hover-color: #f0929f;
52
+
53
+ --hairline-color: #3d3f3e;
54
+ --border-color: #3d3f3e;
55
+
56
+ --error-fg: #ff7a7a;
57
+ --message-success-bg: #173317;
58
+ --message-success-color: #7ddf7d;
59
+ --message-success-border: #2d5a2d;
60
+ --message-warning-bg: #332e17;
61
+ --message-warning-color: #e6c87a;
62
+ --message-warning-border: #5a4d2d;
63
+ --message-error-bg: #331717;
64
+ --message-error-color: #ff7a7a;
65
+ --message-error-border: #5a2d2d;
66
+
67
+ --darkened-bg: #222423;
68
+ --selected-bg: #3d1e24;
69
+ --selected-row: #3d1e24;
70
+ --input-bg: #1f2120;
71
+}
72
+
73
+/* ── Explicit dark theme (beats Django's html[data-theme="dark"] specificity) */
74
+
75
+:root[data-theme="dark"] {
76
+ --primary: #DC394C;
77
+ --secondary: #8B3138;
78
+ --accent: #DC394C;
79
+ --primary-fg: #FAFAFA;
80
+
81
+ --button-fg: #FAFAFA;
82
+ --button-bg: #DC394C;
83
+ --button-hover-bg: #c42d3f;
84
+ --default-button-bg: #8B3138;
85
+ --default-button-hover-bg:#6a1921;
86
+ --close-button-bg: #4a4c4b;
87
+ --close-button-hover-bg: #5a5c5b;
88
+ --delete-button-bg: #8B3138;
89
+ --delete-button-hover-bg: #6a1921;
90
+
91
+ --object-tools-fg: #FAFAFA;
92
+ --object-tools-bg: #8B3138;
93
+ --object-tools-hover-bg: #6a1921;
94
+
95
+ --breadcrumbs-bg: #DC394C;
96
+ --breadcrumbs-fg: #FAFAFA;
97
+ --breadcrumbs-link-fg: #FAFAFA;
98
+ --link-selected-fg: #DC394C;
99
+
100
+ --header-bg: #1f2120;
101
+ --header-color: #FAFAFA;
102
+ --header-branding-color: #FAFAFA;
103
+ --header-link-color: #FAFAFA;
104
+
105
+ --body-fg: #FAFAFA;
106
+ --body-bg: #0d0d0d;
107
+ --body-quiet-color: #a8aaa9;
108
+ --body-loud-color: #ffffff;
109
+
110
+ --link-fg: #e8677a;
111
+ --link-hover-color: #f0929f;
112
+
113
+ --hairline-color: #2a2c2b;
114
+ --border-color: #2a2c2b;
115
+
116
+ --error-fg: #ff7a7a;
117
+ --message-success-bg: #0f1f0f;
118
+ --message-success-color: #7ddf7d;
119
+ --message-success-border: #1e3d1e;
120
+ --message-warning-bg: #1f1c0f;
121
+ --message-warning-color: #e6c87a;
122
+ --message-warning-border: #3d3519;
123
+ --message-error-bg: #1f0f0f;
124
+ --message-error-color: #ff7a7a;
125
+ --message-error-border: #3d1a1a;
126
+
127
+ --darkened-bg: #141514;
128
+ --selected-bg: #2d1219;
129
+ --selected-row: #2d1219;
130
+ --input-bg: #111211;
131
+}
132
+
133
+/* ── Light theme ─────────────────────────────────────────────────────────── */
134
+
135
+:root[data-theme="light"] {
136
+ /* Header — dark gray in light mode */
137
+ --header-bg: #3a3a3a;
138
+ --header-color: #FAFAFA;
139
+ --header-branding-color: #FAFAFA;
140
+ --header-link-color: #FAFAFA;
141
+
142
+ /* Brand accents — must repeat here to beat base.css html[data-theme="light"] specificity */
143
+ --primary: #DC394C;
144
+ --secondary: #8B3138;
145
+ --accent: #DC394C;
146
+ --primary-fg: #FAFAFA;
147
+
148
+ --button-fg: #FAFAFA;
149
+ --button-bg: #DC394C;
150
+ --button-hover-bg: #c42d3f;
151
+ --default-button-bg: #8B3138;
152
+ --default-button-hover-bg:#6a1921;
153
+ --delete-button-bg: #8B3138;
154
+ --delete-button-hover-bg: #6a1921;
155
+
156
+ --object-tools-fg: #FAFAFA;
157
+ --object-tools-bg: #8B3138;
158
+ --object-tools-hover-bg: #6a1921;
159
+
160
+ --breadcrumbs-bg: #DC394C;
161
+ --breadcrumbs-fg: #FAFAFA;
162
+ --breadcrumbs-link-fg: #FAFAFA;
163
+ --link-selected-fg: #DC394C;
164
+
165
+ --body-fg: #1a1a1a;
166
+ --body-bg: #f8f8f8;
167
+ --body-quiet-color: #666666;
168
+ --body-loud-color: #000000;
169
+
170
+ --link-fg: #DC394C;
171
+ --link-hover-color: #8B3138;
172
+
173
+ --hairline-color: #e0e0e0;
174
+ --border-color: #e0e0e0;
175
+
176
+ --error-fg: #c0392b;
177
+ --message-success-bg: #d4edda;
178
+ --message-success-color: #155724;
179
+ --message-success-border: #c3e6cb;
180
+ --message-warning-bg: #fff3cd;
181
+ --message-warning-color: #856404;
182
+ --message-warning-border: #ffeeba;
183
+ --message-error-bg: #f8d7da;
184
+ --message-error-color: #721c24;
185
+ --message-error-border: #f5c6cb;
186
+
187
+ --darkened-bg: #eeeeee;
188
+ --selected-bg: #fde8ea;
189
+ --selected-row: #fde8ea;
190
+ --input-bg: #ffffff;
191
+}
192
+
193
+/* ── System auto: respect prefers-color-scheme light ─────────────────────── */
194
+
195
+@media (prefers-color-scheme: light) {
196
+ :root:not([data-theme="dark"]) {
197
+ /* Header — dark gray in light mode */
198
+ --header-bg: #3a3a3a;
199
+ --header-color: #FAFAFA;
200
+ --header-branding-color: #FAFAFA;
201
+ --header-link-color: #FAFAFA;
202
+
203
+ /* Brand accents */
204
+ --primary: #DC394C;
205
+ --secondary: #8B3138;
206
+ --accent: #DC394C;
207
+ --primary-fg: #FAFAFA;
208
+
209
+ --button-fg: #FAFAFA;
210
+ --button-bg: #DC394C;
211
+ --button-hover-bg: #c42d3f;
212
+ --default-button-bg: #8B3138;
213
+ --default-button-hover-bg:#6a1921;
214
+ --delete-button-bg: #8B3138;
215
+ --delete-button-hover-bg: #6a1921;
216
+
217
+ --object-tools-fg: #FAFAFA;
218
+ --object-tools-bg: #8B3138;
219
+ --object-tools-hover-bg: #6a1921;
220
+
221
+ --breadcrumbs-bg: #DC394C;
222
+ --breadcrumbs-fg: #FAFAFA;
223
+ --breadcrumbs-link-fg: #FAFAFA;
224
+ --link-selected-fg: #DC394C;
225
+
226
+ --body-fg: #1a1a1a;
227
+ --body-bg: #f8f8f8;
228
+ --body-quiet-color: #666666;
229
+ --body-loud-color: #000000;
230
+
231
+ --link-fg: #DC394C;
232
+ --link-hover-color: #8B3138;
233
+
234
+ --hairline-color: #e0e0e0;
235
+ --border-color: #e0e0e0;
236
+
237
+ --error-fg: #c0392b;
238
+ --message-success-bg: #d4edda;
239
+ --message-success-color: #155724;
240
+ --message-success-border: #c3e6cb;
241
+ --message-warning-bg: #fff3cd;
242
+ --message-warning-color: #856404;
243
+ --message-warning-border: #ffeeba;
244
+ --message-error-bg: #f8d7da;
245
+ --message-error-color: #721c24;
246
+ --message-error-border: #f5c6cb;
247
+
248
+ --darkened-bg: #eeeeee;
249
+ --selected-bg: #fde8ea;
250
+ --selected-row: #fde8ea;
251
+ --input-bg: #ffffff;
252
+ }
253
+}
254
+
255
+
256
+/* ── Layout ─────────────────────────────────────────────────────────────── */
257
+
258
+/*
259
+ * Django's stock base.css gives `.module { background: var(--darkened-bg) }`.
260
+ * #changelist has class "module", so the 30px margin gap between the table and
261
+ * the filter also gets --darkened-bg — the same color as the filter itself.
262
+ * Result: no visible gap.
263
+ *
264
+ * clientcove fixes this by shipping a modified base.css with
265
+ * `.module { background: var(--body-bg) }`. We can't change Django's base.css,
266
+ * so we target #changelist specifically to make the gap show the body background.
267
+ */
268
+#changelist.module {
269
+ background: transparent;
270
+ border: none;
271
+}
272
+
273
+/* Prevent table from overflowing into the filter gap — layout owned by changelists.css */
274
+#changelist .changelist-form-container > div {
275
+ overflow-x: auto;
276
+}
277
+
278
+/* ── Header — always dark, hardcoded values intentional ─────────────────── */
279
+
280
+#header {
281
+ background: var(--header-bg);
282
+ border-bottom: 2px solid var(--primary);
283
+}
284
+
285
+#header a:link,
286
+#header a:visited {
287
+ color: var(--header-link-color);
288
+}
289
+
290
+#header #branding h1 {
291
+ line-height: 1;
292
+ margin: 0;
293
+}
294
+
295
+#header #branding .logo img {
296
+ height: 40px;
297
+ width: auto;
298
+ display: block;
299
+}
300
+
301
+#header #user-tools {
302
+ color: #a8aaa9;
303
+}
304
+
305
+#header #user-tools a {
306
+ color: #a8aaa9;
307
+}
308
+
309
+#header #user-tools a:hover {
310
+ color: #FAFAFA;
311
+}
312
+
313
+/* Quick-links group in header */
314
+.bw-links {
315
+ display: inline-flex;
316
+ gap: 2px;
317
+ margin-right: 8px;
318
+ border-right: 1px solid #3d3f3e;
319
+ padding-right: 10px;
320
+}
321
+
322
+.bw-links a {
323
+ display: inline-block;
324
+ padding: 2px 7px;
325
+ border-radius: 3px;
326
+ background: #2e3130;
327
+ color: #a8aaa9 !important;
328
+ font-size: 0.75em;
329
+ text-transform: uppercase;
330
+ letter-spacing: 0.04em;
331
+ text-decoration: none !important;
332
+ transition: background 0.15s, color 0.15s;
333
+}
334
+
335
+.bw-links a:hover {
336
+ background: var(--primary);
337
+ color: #FAFAFA !important;
338
+}
339
+
340
+/* ── Light mode header text overrides ───────────────────────────────────── */
341
+
342
+/* ── Navigation ─────────────────────────────────────────────────────────── */
343
+
344
+div.breadcrumbs {
345
+ background: var(--breadcrumbs-bg);
346
+ color: var(--breadcrumbs-fg);
347
+}
348
+
349
+div.breadcrumbs a {
350
+ color: var(--breadcrumbs-fg);
351
+ opacity: 0.85;
352
+}
353
+
354
+div.breadcrumbs a:hover {
355
+ opacity: 1;
356
+ text-decoration: underline;
357
+}
358
+
359
+/* ── Content area ───────────────────────────────────────────────────────── */
360
+
361
+body {
362
+ background: var(--body-bg);
363
+ color: var(--body-fg);
364
+}
365
+
366
+.module h2,
367
+.module caption,
368
+.inline-group h2 {
369
+ background: var(--darkened-bg);
370
+ color: var(--body-loud-color);
371
+ border-left: 3px solid var(--primary);
372
+ padding-left: 12px;
373
+ font-size: 0.75em;
374
+ font-weight: 600;
375
+ text-transform: uppercase;
376
+ letter-spacing: 0.06em;
377
+}
378
+
379
+.module {
380
+ background: var(--darkened-bg);
381
+ border: 1px solid var(--border-color);
382
+}
383
+
384
+/* ── Sidebar ────────────────────────────────────────────────────────────── */
385
+
386
+#nav-sidebar {
387
+ background: var(--darkened-bg);
388
+ border-right: 1px solid var(--border-color);
389
+}
390
+
391
+#nav-sidebar .current-app .section:link,
392
+#nav-sidebar .current-app .section:visited {
393
+ color: var(--primary);
394
+}
395
+
396
+/* ── Tables ─────────────────────────────────────────────────────────────── */
397
+
398
+#result_list thead th {
399
+ background: var(--input-bg);
400
+ color: var(--body-fg);
401
+ border-bottom: 1px solid var(--border-color);
402
+}
403
+
404
+#result_list thead th a,
405
+#result_list thead th a:visited {
406
+ color: var(--body-fg);
407
+}
408
+
409
+#result_list tr.row1 {
410
+ background: var(--body-bg);
411
+}
412
+
413
+#result_list tr.row2 {
414
+ background: var(--darkened-bg);
415
+}
416
+
417
+#result_list tr:hover td,
418
+#result_list tr.selected td {
419
+ background: var(--selected-bg);
420
+}
421
+
422
+/* ── Forms ──────────────────────────────────────────────────────────────── */
423
+
424
+input, textarea, select,
425
+.form-row input, .form-row textarea, .form-row select {
426
+ background: var(--input-bg);
427
+ color: var(--body-fg);
428
+ border: 1px solid var(--border-color);
429
+}
430
+
431
+input:focus, textarea:focus, select:focus {
432
+ border-color: var(--primary);
433
+ outline: none;
434
+ box-shadow: 0 0 0 2px rgba(220, 57, 76, 0.25);
435
+}
436
+
437
+.form-row {
438
+ border-bottom: 1px solid var(--border-color);
439
+}
440
+
441
+fieldset {
442
+ background: var(--darkened-bg);
443
+ border: 1px solid var(--border-color);
444
+}
445
+
446
+fieldset.collapsed h2 {
447
+ background: var(--input-bg);
448
+ color: var(--body-quiet-color);
449
+}
450
+
451
+/* ── Buttons ────────────────────────────────────────────────────────────── */
452
+
453
+.button, input[type="submit"], input[type="button"], .submit-row input, a.button {
454
+ background: var(--button-bg);
455
+ color: var(--button-fg);
456
+ border: none;
457
+}
458
+
459
+.button:hover, input[type="submit"]:hover, input[type="button"]:hover,
460
+.submit-row input:hover, a.button:hover {
461
+ background: var(--button-hover-bg);
462
+}
463
+
464
+.button.default, input[type="submit"].default, .submit-row input.default {
465
+ background: var(--default-button-bg);
466
+}
467
+
468
+.button.default:hover, input[type="submit"].default:hover,
469
+.submit-row input.default:hover {
470
+ background: var(--default-button-hover-bg);
471
+}
472
+
473
+.deletelink-box a.deletelink,
474
+.object-tools a.deletelink {
475
+ background: var(--delete-button-bg);
476
+}
477
+
478
+.deletelink-box a.deletelink:hover,
479
+.object-tools a.deletelink:hover {
480
+ background: var(--delete-button-hover-bg);
481
+}
482
+
483
+/* ── Dashboard / change list ────────────────────────────────────────────── */
484
+
485
+#changelist .actions {
486
+ background: var(--darkened-bg);
487
+ border: 1px solid var(--border-color);
488
+ border-top: none;
489
+}
490
+
491
+/* Search bar */
492
+#changelist-search input[type="text"] {
493
+ background: var(--input-bg);
494
+ color: var(--body-fg);
495
+ border: 1px solid var(--border-color);
496
+}
497
+
498
+#changelist-search input[type="submit"] {
499
+ background: var(--hairline-color);
500
+ color: var(--body-fg);
501
+ border: none;
502
+}
503
+
504
+#changelist-search input[type="submit"]:hover {
505
+ background: var(--primary);
506
+ color: var(--primary-fg);
507
+}
508
+
509
+/* Action bar */
510
+#changelist .actions label,
511
+#changelist .actions span {
512
+ color: var(--body-quiet-color);
513
+}
514
+
515
+#changelist .actions select {
516
+ background: var(--input-bg);
517
+ color: var(--body-fg);
518
+ border: 1px solid var(--border-color);
519
+}
520
+
521
+/* Filter sidebar */
522
+#changelist-filter {
523
+ background: var(--input-bg);
524
+ border-left: 2px solid var(--border-color);
525
+}
526
+
527
+#changelist-filter h2 {
528
+ background: var(--input-bg);
529
+ color: var(--body-quiet-color);
530
+ font-size: 0.8em;
531
+ text-transform: uppercase;
532
+ letter-spacing: 0.05em;
533
+}
534
+
535
+#changelist-filter h3 {
536
+ color: var(--body-fg);
537
+ border-bottom: 1px solid var(--border-color);
538
+}
539
+
540
+#changelist-filter ul {
541
+ border-top: none;
542
+}
543
+
544
+#changelist-filter li a,
545
+#changelist-filter li a:link,
546
+#changelist-filter li a:visited {
547
+ color: var(--link-fg);
548
+}
549
+
550
+#changelist-filter li a:hover {
551
+ color: var(--link-hover-color);
552
+}
553
+
554
+#changelist-filter li.selected a,
555
+#changelist-filter li.selected {
556
+ color: var(--body-fg);
557
+}
558
+
559
+/* "Show counts" and other filter toolbar links */
560
+#changelist-filter details summary,
561
+#changelist-filter .xfull {
562
+ color: var(--body-quiet-color);
563
+}
564
+
565
+
566
+/* ── Pagination ─────────────────────────────────────────────────────────── */
567
+
568
+.paginator {
569
+ color: var(--body-quiet-color);
570
+ border-top: 1px solid var(--border-color);
571
+}
572
+
573
+.paginator a:link,
574
+.paginator a:visited {
575
+ background: var(--darkened-bg);
576
+ color: var(--body-fg);
577
+ border: 1px solid var(--border-color);
578
+}
579
+
580
+.paginator a:hover {
581
+ background: var(--hairline-color);
582
+ color: var(--body-fg);
583
+ border-color: var(--border-color);
584
+}
585
+
586
+.paginator .this-page {
587
+ background: var(--primary);
588
+ color: var(--primary-fg);
589
+ border: 1px solid var(--primary);
590
+}
591
+
592
+/* ── Login page ─────────────────────────────────────────────────────────── */
593
+
594
+body.login {
595
+ background: var(--header-bg);
596
+}
597
+
598
+body.login #container {
599
+ background: var(--body-bg);
600
+}
601
+
602
+/* ── Messages ───────────────────────────────────────────────────────────── */
603
+
604
+.messagelist li.success {
605
+ background: var(--message-success-bg);
606
+ color: var(--message-success-color);
607
+ border: 1px solid var(--message-success-border);
608
+}
609
+
610
+.messagelist li.warning {
611
+ background: var(--message-warning-bg);
612
+ color: var(--message-warning-color);
613
+ border: 1px solid var(--message-warning-border);
614
+}
615
+
616
+.messagelist li.error {
617
+ background: var(--message-error-bg);
618
+ color: var(--message-error-color);
619
+ border: 1px solid var(--message-error-border);
620
+}
621
+
622
+/* ── Calendar / date widgets ────────────────────────────────────────────── */
623
+
624
+.calendarbox, .clockbox {
625
+ background: var(--darkened-bg);
626
+ border: 1px solid var(--border-color);
627
+}
628
+
629
+.calendar caption,
630
+.calendarbox h2 {
631
+ background: var(--primary);
632
+ color: var(--primary-fg);
633
+}
634
+
635
+.calendar td a:hover {
636
+ background: var(--primary);
637
+ color: var(--primary-fg);
638
+}
639
+
640
+/* ── Misc ───────────────────────────────────────────────────────────────── */
641
+
642
+a:link, a:visited {
643
+ color: var(--link-fg);
644
+}
645
+
646
+a:hover {
647
+ color: var(--link-hover-color);
648
+}
649
+
650
+.help, .help-tooltip {
651
+ color: var(--body-quiet-color);
652
+}
653
+
654
+.errornote {
655
+ background: var(--message-error-bg);
656
+ border: 1px solid var(--secondary);
657
+ color: var(--error-fg);
658
+}
659
+
660
+ul.errorlist {
661
+ color: var(--error-fg);
662
+}
663
+
664
+.inline-related h3,
665
+.inline-related h4 {
666
+ background: var(--input-bg);
667
+ border-top: 2px solid var(--primary);
668
+ color: var(--body-fg);
669
+}
670
+
671
+/* ── Inline formsets (tabular) ──────────────────────────────────────────── */
672
+
673
+.inline-related {
674
+ background: var(--darkened-bg);
675
+ border: 1px solid var(--border-color);
676
+ max-width: 100%;
677
+ overflow-x: auto;
678
+}
679
+
680
+.inline-related .tabular {
681
+ max-width: 100%;
682
+ overflow-x: auto;
683
+ display: block;
684
+}
685
+
686
+.inline-related thead {
687
+ background: var(--input-bg);
688
+}
689
+
690
+.inline-related thead th,
691
+.inline-related thead td {
692
+ background: var(--input-bg);
693
+ color: var(--body-quiet-color);
694
+ border-bottom: 1px solid var(--border-color);
695
+ font-size: 0.75em;
696
+ text-transform: uppercase;
697
+ letter-spacing: 0.05em;
698
+}
699
+
700
+.inline-related tbody tr {
701
+ background: var(--darkened-bg);
702
+ border-bottom: 1px solid var(--border-color);
703
+}
704
+
705
+.inline-related tbody tr:hover {
706
+ background: var(--selected-bg);
707
+}
708
+
709
+.inline-related tbody tr.empty-form {
710
+ display: none;
711
+}
712
+
713
+.inline-related .add-row {
714
+ background: var(--input-bg);
715
+ border-top: 1px solid var(--border-color);
716
+}
717
+
718
+.inline-related .add-row a {
719
+ color: var(--primary);
720
+}
721
+
722
+.inline-related .add-row a:hover {
723
+ color: var(--link-hover-color);
724
+}
725
+
726
+.inline-related td.delete {
727
+ background: var(--darkened-bg);
728
+}
729
+
730
+.inline-related td.original p {
731
+ color: var(--body-quiet-color);
732
+ font-size: 0.8em;
733
+}
734
+
735
+/* ── Read-only field values ──────────────────────────────────────────────── */
736
+
737
+.form-row .readonly {
738
+ color: var(--body-fg);
739
+ padding: 8px 0;
740
+}
741
+
742
+.form-row label {
743
+ color: var(--body-quiet-color);
744
+}
745
+
746
+/* ── Module captions: brand color in light mode ──────────────────────────── */
747
+/* Needs higher specificity than Django's html[data-theme="light"] .module caption */
748
+
749
+/* Nav sidebar section headers — dark gray in all light themes */
750
+/* Scoped to #nav-sidebar so it doesn't hit #changelist-filter h2 */
751
+:root[data-theme="dark"] #nav-sidebar .module caption,
752
+:root[data-theme="dark"] #nav-sidebar .module h2 {
753
+ background: #3a3a3a !important;
754
+ color: #FAFAFA !important;
755
+}
756
+
757
+:root[data-theme="light"] #nav-sidebar .module caption,
758
+:root[data-theme="light"] #nav-sidebar .module h2 {
759
+ background: #3a3a3a !important;
760
+ color: #FAFAFA !important;
761
+}
762
+
763
+@media (prefers-color-scheme: light) {
764
+ :root:not([data-theme="dark"]) #nav-sidebar .module caption,
765
+ :root:not([data-theme="dark"]) #nav-sidebar .module h2 {
766
+ background: #3a3a3a !important;
767
+ color: #FAFAFA !important;
768
+ }
769
+}
--- a/static/admin/css/dark_theme.css
+++ b/static/admin/css/dark_theme.css
@@ -0,0 +1,769 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/static/admin/css/dark_theme.css
+++ b/static/admin/css/dark_theme.css
@@ -0,0 +1,769 @@
1 /*
2 * Fossilrepo Admin Theme — supports dark, light, and system-auto modes
3 * Brand palette:
4 * #2B2D2C charcoal (dark body background)
5 * #DC394C red (primary brand accent)
6 * #8B3138 crimson (secondary / hover states)
7 * #FAFAFA near-white (foreground on dark)
8 */
9
10 /* ── Shared brand accents + dark defaults ───────────────────────────────── */
11
12 :root {
13 /* Brand accents — unchanged across all themes */
14 --primary: #DC394C;
15 --secondary: #8B3138;
16 --accent: #DC394C;
17 --primary-fg: #FAFAFA;
18
19 --button-fg: #FAFAFA;
20 --button-bg: #DC394C;
21 --button-hover-bg: #c42d3f;
22 --default-button-bg: #8B3138;
23 --default-button-hover-bg:#6a1921;
24 --close-button-bg: #4a4c4b;
25 --close-button-hover-bg: #5a5c5b;
26 --delete-button-bg: #8B3138;
27 --delete-button-hover-bg: #6a1921;
28
29 --object-tools-fg: #FAFAFA;
30 --object-tools-bg: #8B3138;
31 --object-tools-hover-bg: #6a1921;
32
33 --breadcrumbs-bg: #DC394C;
34 --breadcrumbs-fg: #FAFAFA;
35 --breadcrumbs-link-fg: #FAFAFA;
36 --link-selected-fg: #DC394C;
37
38 /* Header stays dark in both themes for brand consistency */
39 --header-color: #FAFAFA;
40 --header-branding-color: #FAFAFA;
41 --header-bg: #1f2120;
42 --header-link-color: #FAFAFA;
43
44 /* Dark theme defaults (applied when no explicit data-theme is set) */
45 --body-fg: #FAFAFA;
46 --body-bg: #2B2D2C;
47 --body-quiet-color: #a8aaa9;
48 --body-loud-color: #ffffff;
49
50 --link-fg: #e8677a;
51 --link-hover-color: #f0929f;
52
53 --hairline-color: #3d3f3e;
54 --border-color: #3d3f3e;
55
56 --error-fg: #ff7a7a;
57 --message-success-bg: #173317;
58 --message-success-color: #7ddf7d;
59 --message-success-border: #2d5a2d;
60 --message-warning-bg: #332e17;
61 --message-warning-color: #e6c87a;
62 --message-warning-border: #5a4d2d;
63 --message-error-bg: #331717;
64 --message-error-color: #ff7a7a;
65 --message-error-border: #5a2d2d;
66
67 --darkened-bg: #222423;
68 --selected-bg: #3d1e24;
69 --selected-row: #3d1e24;
70 --input-bg: #1f2120;
71 }
72
73 /* ── Explicit dark theme (beats Django's html[data-theme="dark"] specificity) */
74
75 :root[data-theme="dark"] {
76 --primary: #DC394C;
77 --secondary: #8B3138;
78 --accent: #DC394C;
79 --primary-fg: #FAFAFA;
80
81 --button-fg: #FAFAFA;
82 --button-bg: #DC394C;
83 --button-hover-bg: #c42d3f;
84 --default-button-bg: #8B3138;
85 --default-button-hover-bg:#6a1921;
86 --close-button-bg: #4a4c4b;
87 --close-button-hover-bg: #5a5c5b;
88 --delete-button-bg: #8B3138;
89 --delete-button-hover-bg: #6a1921;
90
91 --object-tools-fg: #FAFAFA;
92 --object-tools-bg: #8B3138;
93 --object-tools-hover-bg: #6a1921;
94
95 --breadcrumbs-bg: #DC394C;
96 --breadcrumbs-fg: #FAFAFA;
97 --breadcrumbs-link-fg: #FAFAFA;
98 --link-selected-fg: #DC394C;
99
100 --header-bg: #1f2120;
101 --header-color: #FAFAFA;
102 --header-branding-color: #FAFAFA;
103 --header-link-color: #FAFAFA;
104
105 --body-fg: #FAFAFA;
106 --body-bg: #0d0d0d;
107 --body-quiet-color: #a8aaa9;
108 --body-loud-color: #ffffff;
109
110 --link-fg: #e8677a;
111 --link-hover-color: #f0929f;
112
113 --hairline-color: #2a2c2b;
114 --border-color: #2a2c2b;
115
116 --error-fg: #ff7a7a;
117 --message-success-bg: #0f1f0f;
118 --message-success-color: #7ddf7d;
119 --message-success-border: #1e3d1e;
120 --message-warning-bg: #1f1c0f;
121 --message-warning-color: #e6c87a;
122 --message-warning-border: #3d3519;
123 --message-error-bg: #1f0f0f;
124 --message-error-color: #ff7a7a;
125 --message-error-border: #3d1a1a;
126
127 --darkened-bg: #141514;
128 --selected-bg: #2d1219;
129 --selected-row: #2d1219;
130 --input-bg: #111211;
131 }
132
133 /* ── Light theme ─────────────────────────────────────────────────────────── */
134
135 :root[data-theme="light"] {
136 /* Header — dark gray in light mode */
137 --header-bg: #3a3a3a;
138 --header-color: #FAFAFA;
139 --header-branding-color: #FAFAFA;
140 --header-link-color: #FAFAFA;
141
142 /* Brand accents — must repeat here to beat base.css html[data-theme="light"] specificity */
143 --primary: #DC394C;
144 --secondary: #8B3138;
145 --accent: #DC394C;
146 --primary-fg: #FAFAFA;
147
148 --button-fg: #FAFAFA;
149 --button-bg: #DC394C;
150 --button-hover-bg: #c42d3f;
151 --default-button-bg: #8B3138;
152 --default-button-hover-bg:#6a1921;
153 --delete-button-bg: #8B3138;
154 --delete-button-hover-bg: #6a1921;
155
156 --object-tools-fg: #FAFAFA;
157 --object-tools-bg: #8B3138;
158 --object-tools-hover-bg: #6a1921;
159
160 --breadcrumbs-bg: #DC394C;
161 --breadcrumbs-fg: #FAFAFA;
162 --breadcrumbs-link-fg: #FAFAFA;
163 --link-selected-fg: #DC394C;
164
165 --body-fg: #1a1a1a;
166 --body-bg: #f8f8f8;
167 --body-quiet-color: #666666;
168 --body-loud-color: #000000;
169
170 --link-fg: #DC394C;
171 --link-hover-color: #8B3138;
172
173 --hairline-color: #e0e0e0;
174 --border-color: #e0e0e0;
175
176 --error-fg: #c0392b;
177 --message-success-bg: #d4edda;
178 --message-success-color: #155724;
179 --message-success-border: #c3e6cb;
180 --message-warning-bg: #fff3cd;
181 --message-warning-color: #856404;
182 --message-warning-border: #ffeeba;
183 --message-error-bg: #f8d7da;
184 --message-error-color: #721c24;
185 --message-error-border: #f5c6cb;
186
187 --darkened-bg: #eeeeee;
188 --selected-bg: #fde8ea;
189 --selected-row: #fde8ea;
190 --input-bg: #ffffff;
191 }
192
193 /* ── System auto: respect prefers-color-scheme light ─────────────────────── */
194
195 @media (prefers-color-scheme: light) {
196 :root:not([data-theme="dark"]) {
197 /* Header — dark gray in light mode */
198 --header-bg: #3a3a3a;
199 --header-color: #FAFAFA;
200 --header-branding-color: #FAFAFA;
201 --header-link-color: #FAFAFA;
202
203 /* Brand accents */
204 --primary: #DC394C;
205 --secondary: #8B3138;
206 --accent: #DC394C;
207 --primary-fg: #FAFAFA;
208
209 --button-fg: #FAFAFA;
210 --button-bg: #DC394C;
211 --button-hover-bg: #c42d3f;
212 --default-button-bg: #8B3138;
213 --default-button-hover-bg:#6a1921;
214 --delete-button-bg: #8B3138;
215 --delete-button-hover-bg: #6a1921;
216
217 --object-tools-fg: #FAFAFA;
218 --object-tools-bg: #8B3138;
219 --object-tools-hover-bg: #6a1921;
220
221 --breadcrumbs-bg: #DC394C;
222 --breadcrumbs-fg: #FAFAFA;
223 --breadcrumbs-link-fg: #FAFAFA;
224 --link-selected-fg: #DC394C;
225
226 --body-fg: #1a1a1a;
227 --body-bg: #f8f8f8;
228 --body-quiet-color: #666666;
229 --body-loud-color: #000000;
230
231 --link-fg: #DC394C;
232 --link-hover-color: #8B3138;
233
234 --hairline-color: #e0e0e0;
235 --border-color: #e0e0e0;
236
237 --error-fg: #c0392b;
238 --message-success-bg: #d4edda;
239 --message-success-color: #155724;
240 --message-success-border: #c3e6cb;
241 --message-warning-bg: #fff3cd;
242 --message-warning-color: #856404;
243 --message-warning-border: #ffeeba;
244 --message-error-bg: #f8d7da;
245 --message-error-color: #721c24;
246 --message-error-border: #f5c6cb;
247
248 --darkened-bg: #eeeeee;
249 --selected-bg: #fde8ea;
250 --selected-row: #fde8ea;
251 --input-bg: #ffffff;
252 }
253 }
254
255
256 /* ── Layout ─────────────────────────────────────────────────────────────── */
257
258 /*
259 * Django's stock base.css gives `.module { background: var(--darkened-bg) }`.
260 * #changelist has class "module", so the 30px margin gap between the table and
261 * the filter also gets --darkened-bg — the same color as the filter itself.
262 * Result: no visible gap.
263 *
264 * clientcove fixes this by shipping a modified base.css with
265 * `.module { background: var(--body-bg) }`. We can't change Django's base.css,
266 * so we target #changelist specifically to make the gap show the body background.
267 */
268 #changelist.module {
269 background: transparent;
270 border: none;
271 }
272
273 /* Prevent table from overflowing into the filter gap — layout owned by changelists.css */
274 #changelist .changelist-form-container > div {
275 overflow-x: auto;
276 }
277
278 /* ── Header — always dark, hardcoded values intentional ─────────────────── */
279
280 #header {
281 background: var(--header-bg);
282 border-bottom: 2px solid var(--primary);
283 }
284
285 #header a:link,
286 #header a:visited {
287 color: var(--header-link-color);
288 }
289
290 #header #branding h1 {
291 line-height: 1;
292 margin: 0;
293 }
294
295 #header #branding .logo img {
296 height: 40px;
297 width: auto;
298 display: block;
299 }
300
301 #header #user-tools {
302 color: #a8aaa9;
303 }
304
305 #header #user-tools a {
306 color: #a8aaa9;
307 }
308
309 #header #user-tools a:hover {
310 color: #FAFAFA;
311 }
312
313 /* Quick-links group in header */
314 .bw-links {
315 display: inline-flex;
316 gap: 2px;
317 margin-right: 8px;
318 border-right: 1px solid #3d3f3e;
319 padding-right: 10px;
320 }
321
322 .bw-links a {
323 display: inline-block;
324 padding: 2px 7px;
325 border-radius: 3px;
326 background: #2e3130;
327 color: #a8aaa9 !important;
328 font-size: 0.75em;
329 text-transform: uppercase;
330 letter-spacing: 0.04em;
331 text-decoration: none !important;
332 transition: background 0.15s, color 0.15s;
333 }
334
335 .bw-links a:hover {
336 background: var(--primary);
337 color: #FAFAFA !important;
338 }
339
340 /* ── Light mode header text overrides ───────────────────────────────────── */
341
342 /* ── Navigation ─────────────────────────────────────────────────────────── */
343
344 div.breadcrumbs {
345 background: var(--breadcrumbs-bg);
346 color: var(--breadcrumbs-fg);
347 }
348
349 div.breadcrumbs a {
350 color: var(--breadcrumbs-fg);
351 opacity: 0.85;
352 }
353
354 div.breadcrumbs a:hover {
355 opacity: 1;
356 text-decoration: underline;
357 }
358
359 /* ── Content area ───────────────────────────────────────────────────────── */
360
361 body {
362 background: var(--body-bg);
363 color: var(--body-fg);
364 }
365
366 .module h2,
367 .module caption,
368 .inline-group h2 {
369 background: var(--darkened-bg);
370 color: var(--body-loud-color);
371 border-left: 3px solid var(--primary);
372 padding-left: 12px;
373 font-size: 0.75em;
374 font-weight: 600;
375 text-transform: uppercase;
376 letter-spacing: 0.06em;
377 }
378
379 .module {
380 background: var(--darkened-bg);
381 border: 1px solid var(--border-color);
382 }
383
384 /* ── Sidebar ────────────────────────────────────────────────────────────── */
385
386 #nav-sidebar {
387 background: var(--darkened-bg);
388 border-right: 1px solid var(--border-color);
389 }
390
391 #nav-sidebar .current-app .section:link,
392 #nav-sidebar .current-app .section:visited {
393 color: var(--primary);
394 }
395
396 /* ── Tables ─────────────────────────────────────────────────────────────── */
397
398 #result_list thead th {
399 background: var(--input-bg);
400 color: var(--body-fg);
401 border-bottom: 1px solid var(--border-color);
402 }
403
404 #result_list thead th a,
405 #result_list thead th a:visited {
406 color: var(--body-fg);
407 }
408
409 #result_list tr.row1 {
410 background: var(--body-bg);
411 }
412
413 #result_list tr.row2 {
414 background: var(--darkened-bg);
415 }
416
417 #result_list tr:hover td,
418 #result_list tr.selected td {
419 background: var(--selected-bg);
420 }
421
422 /* ── Forms ──────────────────────────────────────────────────────────────── */
423
424 input, textarea, select,
425 .form-row input, .form-row textarea, .form-row select {
426 background: var(--input-bg);
427 color: var(--body-fg);
428 border: 1px solid var(--border-color);
429 }
430
431 input:focus, textarea:focus, select:focus {
432 border-color: var(--primary);
433 outline: none;
434 box-shadow: 0 0 0 2px rgba(220, 57, 76, 0.25);
435 }
436
437 .form-row {
438 border-bottom: 1px solid var(--border-color);
439 }
440
441 fieldset {
442 background: var(--darkened-bg);
443 border: 1px solid var(--border-color);
444 }
445
446 fieldset.collapsed h2 {
447 background: var(--input-bg);
448 color: var(--body-quiet-color);
449 }
450
451 /* ── Buttons ────────────────────────────────────────────────────────────── */
452
453 .button, input[type="submit"], input[type="button"], .submit-row input, a.button {
454 background: var(--button-bg);
455 color: var(--button-fg);
456 border: none;
457 }
458
459 .button:hover, input[type="submit"]:hover, input[type="button"]:hover,
460 .submit-row input:hover, a.button:hover {
461 background: var(--button-hover-bg);
462 }
463
464 .button.default, input[type="submit"].default, .submit-row input.default {
465 background: var(--default-button-bg);
466 }
467
468 .button.default:hover, input[type="submit"].default:hover,
469 .submit-row input.default:hover {
470 background: var(--default-button-hover-bg);
471 }
472
473 .deletelink-box a.deletelink,
474 .object-tools a.deletelink {
475 background: var(--delete-button-bg);
476 }
477
478 .deletelink-box a.deletelink:hover,
479 .object-tools a.deletelink:hover {
480 background: var(--delete-button-hover-bg);
481 }
482
483 /* ── Dashboard / change list ────────────────────────────────────────────── */
484
485 #changelist .actions {
486 background: var(--darkened-bg);
487 border: 1px solid var(--border-color);
488 border-top: none;
489 }
490
491 /* Search bar */
492 #changelist-search input[type="text"] {
493 background: var(--input-bg);
494 color: var(--body-fg);
495 border: 1px solid var(--border-color);
496 }
497
498 #changelist-search input[type="submit"] {
499 background: var(--hairline-color);
500 color: var(--body-fg);
501 border: none;
502 }
503
504 #changelist-search input[type="submit"]:hover {
505 background: var(--primary);
506 color: var(--primary-fg);
507 }
508
509 /* Action bar */
510 #changelist .actions label,
511 #changelist .actions span {
512 color: var(--body-quiet-color);
513 }
514
515 #changelist .actions select {
516 background: var(--input-bg);
517 color: var(--body-fg);
518 border: 1px solid var(--border-color);
519 }
520
521 /* Filter sidebar */
522 #changelist-filter {
523 background: var(--input-bg);
524 border-left: 2px solid var(--border-color);
525 }
526
527 #changelist-filter h2 {
528 background: var(--input-bg);
529 color: var(--body-quiet-color);
530 font-size: 0.8em;
531 text-transform: uppercase;
532 letter-spacing: 0.05em;
533 }
534
535 #changelist-filter h3 {
536 color: var(--body-fg);
537 border-bottom: 1px solid var(--border-color);
538 }
539
540 #changelist-filter ul {
541 border-top: none;
542 }
543
544 #changelist-filter li a,
545 #changelist-filter li a:link,
546 #changelist-filter li a:visited {
547 color: var(--link-fg);
548 }
549
550 #changelist-filter li a:hover {
551 color: var(--link-hover-color);
552 }
553
554 #changelist-filter li.selected a,
555 #changelist-filter li.selected {
556 color: var(--body-fg);
557 }
558
559 /* "Show counts" and other filter toolbar links */
560 #changelist-filter details summary,
561 #changelist-filter .xfull {
562 color: var(--body-quiet-color);
563 }
564
565
566 /* ── Pagination ─────────────────────────────────────────────────────────── */
567
568 .paginator {
569 color: var(--body-quiet-color);
570 border-top: 1px solid var(--border-color);
571 }
572
573 .paginator a:link,
574 .paginator a:visited {
575 background: var(--darkened-bg);
576 color: var(--body-fg);
577 border: 1px solid var(--border-color);
578 }
579
580 .paginator a:hover {
581 background: var(--hairline-color);
582 color: var(--body-fg);
583 border-color: var(--border-color);
584 }
585
586 .paginator .this-page {
587 background: var(--primary);
588 color: var(--primary-fg);
589 border: 1px solid var(--primary);
590 }
591
592 /* ── Login page ─────────────────────────────────────────────────────────── */
593
594 body.login {
595 background: var(--header-bg);
596 }
597
598 body.login #container {
599 background: var(--body-bg);
600 }
601
602 /* ── Messages ───────────────────────────────────────────────────────────── */
603
604 .messagelist li.success {
605 background: var(--message-success-bg);
606 color: var(--message-success-color);
607 border: 1px solid var(--message-success-border);
608 }
609
610 .messagelist li.warning {
611 background: var(--message-warning-bg);
612 color: var(--message-warning-color);
613 border: 1px solid var(--message-warning-border);
614 }
615
616 .messagelist li.error {
617 background: var(--message-error-bg);
618 color: var(--message-error-color);
619 border: 1px solid var(--message-error-border);
620 }
621
622 /* ── Calendar / date widgets ────────────────────────────────────────────── */
623
624 .calendarbox, .clockbox {
625 background: var(--darkened-bg);
626 border: 1px solid var(--border-color);
627 }
628
629 .calendar caption,
630 .calendarbox h2 {
631 background: var(--primary);
632 color: var(--primary-fg);
633 }
634
635 .calendar td a:hover {
636 background: var(--primary);
637 color: var(--primary-fg);
638 }
639
640 /* ── Misc ───────────────────────────────────────────────────────────────── */
641
642 a:link, a:visited {
643 color: var(--link-fg);
644 }
645
646 a:hover {
647 color: var(--link-hover-color);
648 }
649
650 .help, .help-tooltip {
651 color: var(--body-quiet-color);
652 }
653
654 .errornote {
655 background: var(--message-error-bg);
656 border: 1px solid var(--secondary);
657 color: var(--error-fg);
658 }
659
660 ul.errorlist {
661 color: var(--error-fg);
662 }
663
664 .inline-related h3,
665 .inline-related h4 {
666 background: var(--input-bg);
667 border-top: 2px solid var(--primary);
668 color: var(--body-fg);
669 }
670
671 /* ── Inline formsets (tabular) ──────────────────────────────────────────── */
672
673 .inline-related {
674 background: var(--darkened-bg);
675 border: 1px solid var(--border-color);
676 max-width: 100%;
677 overflow-x: auto;
678 }
679
680 .inline-related .tabular {
681 max-width: 100%;
682 overflow-x: auto;
683 display: block;
684 }
685
686 .inline-related thead {
687 background: var(--input-bg);
688 }
689
690 .inline-related thead th,
691 .inline-related thead td {
692 background: var(--input-bg);
693 color: var(--body-quiet-color);
694 border-bottom: 1px solid var(--border-color);
695 font-size: 0.75em;
696 text-transform: uppercase;
697 letter-spacing: 0.05em;
698 }
699
700 .inline-related tbody tr {
701 background: var(--darkened-bg);
702 border-bottom: 1px solid var(--border-color);
703 }
704
705 .inline-related tbody tr:hover {
706 background: var(--selected-bg);
707 }
708
709 .inline-related tbody tr.empty-form {
710 display: none;
711 }
712
713 .inline-related .add-row {
714 background: var(--input-bg);
715 border-top: 1px solid var(--border-color);
716 }
717
718 .inline-related .add-row a {
719 color: var(--primary);
720 }
721
722 .inline-related .add-row a:hover {
723 color: var(--link-hover-color);
724 }
725
726 .inline-related td.delete {
727 background: var(--darkened-bg);
728 }
729
730 .inline-related td.original p {
731 color: var(--body-quiet-color);
732 font-size: 0.8em;
733 }
734
735 /* ── Read-only field values ──────────────────────────────────────────────── */
736
737 .form-row .readonly {
738 color: var(--body-fg);
739 padding: 8px 0;
740 }
741
742 .form-row label {
743 color: var(--body-quiet-color);
744 }
745
746 /* ── Module captions: brand color in light mode ──────────────────────────── */
747 /* Needs higher specificity than Django's html[data-theme="light"] .module caption */
748
749 /* Nav sidebar section headers — dark gray in all light themes */
750 /* Scoped to #nav-sidebar so it doesn't hit #changelist-filter h2 */
751 :root[data-theme="dark"] #nav-sidebar .module caption,
752 :root[data-theme="dark"] #nav-sidebar .module h2 {
753 background: #3a3a3a !important;
754 color: #FAFAFA !important;
755 }
756
757 :root[data-theme="light"] #nav-sidebar .module caption,
758 :root[data-theme="light"] #nav-sidebar .module h2 {
759 background: #3a3a3a !important;
760 color: #FAFAFA !important;
761 }
762
763 @media (prefers-color-scheme: light) {
764 :root:not([data-theme="dark"]) #nav-sidebar .module caption,
765 :root:not([data-theme="dark"]) #nav-sidebar .module h2 {
766 background: #3a3a3a !important;
767 color: #FAFAFA !important;
768 }
769 }
--- a/static/admin/img/logo-dark.svg
+++ b/static/admin/img/logo-dark.svg
@@ -0,0 +1,110 @@
1
+<svg width="1033" height="213" viewBox="0 0 1033 213" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+<g filter="url(#filter0_d_303_614)">
3
+<rect x="4" width="1025" height="205" fill="#2B2D2C"/>
4
+<path d="M491.56 167H500.064V163.116H495.59V132.884H500.138V129H491.56V167Z" fill="#DB394C"/>
5
+<path d="M506.018 129V132.884H510.529V163.116H505.981V167H514.56V129H506.018Z" fill="#DB394C"/>
6
+<path d="M505.093 139.932H501.026V155.788H505.093V139.932Z" fill="#DB394C"/>
7
+<path d="M148.624 101.062C168.554 83.5327 166.323 44.969 144.11 30.1725C134.17 22.9805 119.767 19.0365 107.519 19.1396C101.434 19.6293 95.7028 22.4134 90.7074 26.048C84.6217 30.5592 79.7024 35.9209 72.9573 40.2774C65.1219 44.969 54.1422 50.4597 44.0754 49.2223C37.3303 47.3921 37.1782 40.8703 37.4825 34.9929C37.4825 34.9414 37.4825 34.8641 37.4825 34.8125C37.2796 34.8125 37.1275 34.8641 36.9246 34.8641C34.4903 34.8641 32.4871 34.1681 30.8896 33.0854C30.8135 33.7041 30.7374 34.3485 30.7374 34.9929C29.6724 46.129 34.2367 55.873 46.2814 56.3628C61.9776 56.5175 77.0145 46.7734 88.0956 36.9005C97.5793 27.9556 106.125 23.3156 119.006 26.7956C132.522 29.1929 144.871 36.1014 151.311 48.6294C160.034 65.849 155.216 92.9158 136.477 101.01C155.216 109.079 160.034 136.171 151.311 153.391C144.871 165.919 132.395 172.518 118.879 174.915C105.947 178.395 97.63 174.09 88.0956 165.12C77.0145 155.247 61.9523 145.503 46.2814 145.657C34.2367 146.199 29.6724 155.865 30.7374 167.053C30.7374 173.523 34.5917 179.117 40.0943 181.54C40.0182 180.354 40.0943 179.194 40.3986 178.112C40.7536 176.849 41.2861 175.766 41.9454 174.812C39.2575 173.265 37.4317 170.378 37.4317 167.053C37.1275 161.201 37.2542 154.654 44.0246 152.824C54.0661 151.586 65.0459 157.077 72.9066 161.768C79.6517 166.125 84.571 171.513 90.6567 175.998C95.6521 179.633 101.383 182.417 107.469 182.906C119.716 183.009 134.119 179.065 144.059 171.873C166.272 157.103 168.529 118.539 148.573 100.984L148.624 101.062Z" fill="#DC394C"/>
8
+<path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="#DC394C"/>
9
+<g opacity="0.2">
10
+<path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="url(#paint0_linear_303_614)"/>
11
+</g>
12
+<path d="M37.7352 41.4374C37.4056 39.3236 37.4563 37.0294 37.5577 34.8382C37.5577 29.8889 41.5135 25.8676 46.3821 25.8676H79.5241C80.158 25.8676 80.7412 25.6356 81.223 25.2231C83.6827 23.1093 86.8523 20.7893 89.8445 19.0364H46.4074C37.8367 19.0364 30.8634 26.1253 30.8634 34.8382C30.483 38.7823 30.8381 42.5716 31.9538 45.8196C33.3991 43.7574 35.4277 42.1076 37.7606 41.4632L37.7352 41.4374Z" fill="url(#paint1_linear_303_614)"/>
13
+<path d="M37.7352 160.402C37.4056 162.516 37.4563 164.81 37.5577 167.001C37.5577 171.951 41.5135 175.972 46.3821 175.972H79.5241C80.158 175.972 80.7412 176.204 81.223 176.617C83.6827 178.73 86.8523 181.05 89.8445 182.803H46.4074C37.8367 182.803 30.8634 175.714 30.8634 167.001C30.483 163.057 30.8381 159.268 31.9538 156.02C33.3991 158.082 35.4277 159.732 37.7606 160.376L37.7352 160.402Z" fill="url(#paint2_linear_303_614)"/>
14
+<path d="M102.042 111.167C102.042 111.167 35.8849 111.167 30.5852 111.167V117.998H102.042C107.494 117.998 111.779 119.183 114.848 121.555C119.615 124.88 120.604 139.11 114.366 142.719C106.48 148.209 82.0859 144.781 72.8051 145.683C76.5833 147.436 80.3616 149.292 83.6327 151.483C84.5962 152.128 85.712 152.488 86.853 152.463C103.437 152.179 125.929 157.129 125.396 132.665C125.599 117.276 115.938 111.321 102.042 111.167Z" fill="url(#paint3_linear_303_614)"/>
15
+<path d="M87.7147 49.2997C86.3454 49.2997 84.9761 49.6606 83.8097 50.4339C80.6146 52.5735 76.9125 54.3779 73.2103 56.0793C82.77 56.9815 106.226 53.5273 114.34 59.0437C120.578 62.6526 119.563 76.9078 114.822 80.2074C111.753 82.5531 107.468 83.7647 102.016 83.7647H30.5593V90.5958H102.016C115.912 90.4669 125.573 84.4865 125.37 69.0971C125.928 44.8401 104.324 49.5059 87.6893 49.2997H87.7147Z" fill="url(#paint4_linear_303_614)"/>
16
+<path d="M121.263 126.427C120.198 126.608 119.133 126.814 118.068 127.072C119.539 132.614 118.702 140.218 114.391 142.744C106.505 148.235 82.1114 144.807 72.8306 145.709C76.6088 147.462 80.3871 149.318 83.6582 151.509C84.6217 152.153 85.7375 152.514 86.8785 152.489C103.462 152.205 125.954 157.154 125.422 132.691C125.447 130.062 125.168 127.768 124.661 125.654C123.596 126.04 122.48 126.298 121.288 126.427H121.263Z" fill="url(#paint5_linear_303_614)"/>
17
+<g filter="url(#filter1_d_303_614)">
18
+<rect x="195.06" y="32.5" width="803.841" height="76.1916" stroke="#2B2D2C" shape-rendering="crispEdges"/>
19
+<path fill-rule="evenodd" clip-rule="evenodd" d="M549.696 108.274V32.9176H582.786C588.268 32.9176 593.264 33.9167 597.661 36.0512C602.078 38.1951 605.594 41.3351 608.098 45.4472C610.637 49.6163 611.813 54.4753 611.813 59.8467C611.813 65.2685 610.6 70.1409 607.941 74.2414C606.304 76.7879 604.248 78.9243 601.812 80.6522L616.857 108.274H587.652L576.157 86.6632V108.274H549.696ZM590.127 104.154H609.921L596.291 79.1301C597.57 78.5163 598.748 77.8075 599.824 77.0036C601.677 75.6192 603.23 73.9531 604.481 72.005C606.622 68.706 607.693 64.6532 607.693 59.8467C607.693 55.0838 606.655 50.9983 604.579 47.59C602.504 44.1817 599.598 41.5709 595.862 39.7576C592.126 37.9442 587.768 37.0375 582.786 37.0375H553.816V104.154H572.037V82.0004H578.343L590.127 104.154ZM572.037 67.843H578.46C580.601 67.843 582.426 67.5808 583.933 67.0565C585.463 66.5103 586.631 65.6473 587.44 64.4675C588.27 63.2877 588.685 61.7474 588.685 59.8467C588.685 57.9241 588.27 56.3619 587.44 55.1603C586.631 53.9368 585.463 53.0411 583.933 52.473C582.426 51.8831 580.601 51.5882 578.46 51.5882H572.037V67.843ZM576.157 55.7081V63.7231H578.46C580.277 63.7231 581.612 63.499 582.563 63.171C583.38 62.8765 583.79 62.5051 584.041 62.1388L584.056 62.1176L584.071 62.0965C584.278 61.8011 584.565 61.1655 584.565 59.8467C584.565 58.4992 584.275 57.8278 584.05 57.5022L584.026 57.467L584.002 57.4314C583.73 57.019 583.305 56.6347 582.499 56.3351L582.465 56.3227L582.432 56.3096C581.53 55.9566 580.244 55.7081 578.46 55.7081H576.157Z" fill="#FAFAFA"/>
20
+<path fill-rule="evenodd" clip-rule="evenodd" d="M475.677 108.274V32.9176H532.288V55.8391H502.138V59.1351H529.797V82.0566H502.138V85.3525H532.157V108.274H475.677ZM498.018 89.4724V77.9367H525.677V63.2549H498.018V51.7193H528.168V37.0375H479.797V104.154H528.037V89.4724H498.018Z" fill="#FAFAFA"/>
21
+<path fill-rule="evenodd" clip-rule="evenodd" d="M406.344 108.274V32.9176H432.805V85.3525H459.94V108.274H406.344ZM428.685 89.4724V37.0375H410.464V104.154H455.82V89.4724H428.685Z" fill="#FAFAFA"/>
22
+<path fill-rule="evenodd" clip-rule="evenodd" d="M389.337 32.9176V108.274H362.876V32.9176H389.337ZM385.217 37.0375H366.996V104.154H385.217V37.0375Z" fill="#FAFAFA"/>
23
+<path fill-rule="evenodd" clip-rule="evenodd" d="M341.181 91.5344L341.177 91.5409C337.907 97.2938 333.433 101.742 327.775 104.771L327.768 104.775L327.762 104.778C322.179 107.742 316.001 109.192 309.313 109.192C302.581 109.192 296.376 107.731 290.786 104.738L290.776 104.733L290.767 104.728C285.138 101.676 280.682 97.2211 277.416 91.4754L277.411 91.4663L277.406 91.4573C274.097 85.5658 272.552 78.5492 272.552 70.5958C272.552 62.6044 274.095 55.5718 277.41 49.6951L277.413 49.69C280.679 43.9181 285.143 39.4637 290.789 36.4517C296.378 33.46 302.582 32 309.313 32C316.004 32 322.185 33.4617 327.769 36.4501C333.435 39.4604 337.911 43.9139 341.18 49.6886C344.519 55.5668 346.074 62.602 346.074 70.5958C346.074 78.5928 344.518 85.6372 341.181 91.5344ZM325.83 40.0853C320.892 37.4417 315.387 36.1199 309.313 36.1199C303.196 36.1199 297.668 37.4417 292.731 40.0853C287.815 42.707 283.904 46.585 280.998 51.7193C278.114 56.8317 276.672 63.1239 276.672 70.5958C276.672 78.0241 278.114 84.3054 280.998 89.4396C283.904 94.552 287.815 98.4409 292.731 101.106C297.668 103.75 303.196 105.072 309.313 105.072C315.387 105.072 320.892 103.761 325.83 101.139C330.768 98.4956 334.689 94.6176 337.595 89.5052C340.501 84.3709 341.954 78.0678 341.954 70.5958C341.954 63.1239 340.501 56.8317 337.595 51.7193C334.689 46.585 330.768 42.707 325.83 40.0853ZM317.756 61.9115L317.749 61.8931C316.943 59.7622 315.851 58.4077 314.59 57.5445L314.569 57.5305L314.549 57.5161C313.319 56.6524 311.655 56.1013 309.313 56.1013C306.973 56.1013 305.289 56.6519 304.028 57.5279L304.02 57.533C302.77 58.3979 301.668 59.7627 300.838 61.9119C300.021 64.0587 299.538 66.9113 299.538 70.5958C299.538 74.2732 300.019 77.1389 300.838 79.3129C301.662 81.4232 302.759 82.7991 304.024 83.6938C305.285 84.5503 306.973 85.0903 309.313 85.0903C311.665 85.0903 313.337 84.545 314.569 83.6941C315.854 82.8 316.95 81.4286 317.753 79.3212L317.756 79.313L317.759 79.3048C318.595 77.1376 319.088 74.2759 319.088 70.5958C319.088 66.9141 318.594 64.0686 317.763 61.9298L317.756 61.9115ZM316.916 87.08C314.862 88.5002 312.328 89.2102 309.313 89.2102C306.298 89.2102 303.753 88.5002 301.677 87.08C299.624 85.6381 298.061 83.5407 296.991 80.7879C295.942 78.0132 295.418 74.6158 295.418 70.5958C295.418 66.5758 295.942 63.1894 296.991 60.4366C298.061 57.6619 299.624 55.5645 301.677 54.1444C303.753 52.7024 306.298 51.9814 309.313 51.9814C312.328 51.9814 314.862 52.7024 316.916 54.1444C318.992 55.5645 320.554 57.6619 321.602 60.4366C322.673 63.1894 323.208 66.5758 323.208 70.5958C323.208 74.6158 322.673 78.0132 321.602 80.7879C320.554 83.5407 318.992 85.6381 316.916 87.08Z" fill="#FAFAFA"/>
24
+<path fill-rule="evenodd" clip-rule="evenodd" d="M194.56 108.274V32.9176H227.781C233.232 32.9176 238.075 33.6472 242.173 35.2702C246.242 36.8731 249.59 39.253 251.916 42.5486C254.255 45.8309 255.365 49.6469 255.365 53.8167C255.365 56.9112 254.675 59.8338 253.244 62.5031C252.104 64.6623 250.581 66.5265 248.711 68.0855C251.447 69.7483 253.658 71.9966 255.309 74.7798C257.146 77.8531 257.987 81.3596 257.987 85.1465C257.987 89.576 256.814 93.6497 254.409 97.2336C252.037 100.802 248.706 103.516 244.595 105.431C240.412 107.379 235.595 108.274 230.271 108.274H194.56ZM244.445 66.1032C244.662 65.967 244.875 65.8259 245.084 65.68C247.029 64.3036 248.536 62.5995 249.607 60.5676C250.699 58.5358 251.245 56.2855 251.245 53.8167C251.245 50.4084 250.35 47.448 248.558 44.9355C246.788 42.423 244.156 40.4785 240.66 39.1021C237.186 37.7257 232.893 37.0375 227.781 37.0375H198.679V104.154H230.271C235.143 104.154 239.338 103.335 242.856 101.696C246.373 100.058 249.082 97.8073 250.983 94.9453C252.906 92.0832 253.867 88.8169 253.867 85.1465C253.867 81.9786 253.168 79.2257 251.77 76.888C250.371 74.5284 248.493 72.6823 246.133 71.3496C245.682 71.0907 245.222 70.8546 244.752 70.6413C242.8 69.7553 240.683 69.2613 238.401 69.1594C238.357 69.1574 238.312 69.1556 238.268 69.1539V68.4984C238.276 68.4967 238.284 68.4951 238.293 68.4934C240.602 68.0248 242.653 67.2281 244.445 66.1032ZM228.809 80.0833L228.797 80.0783C228.133 79.7907 227.138 79.5659 225.683 79.5659H221.02V85.4836H225.421C228.065 85.4836 229.319 84.9764 229.832 84.6027C230.143 84.3635 230.477 83.9794 230.477 82.7869C230.477 81.8557 230.271 81.3515 230.077 81.0582C229.849 80.7117 229.491 80.3743 228.82 80.0881L228.809 80.0833ZM232.303 87.8993C230.774 89.0354 228.48 89.6035 225.421 89.6035H216.901V75.446H225.683C227.54 75.446 229.124 75.7301 230.435 76.2981C231.768 76.8662 232.795 77.6964 233.516 78.7888C234.237 79.8812 234.597 81.2139 234.597 82.7869C234.597 85.0373 233.833 86.7414 232.303 87.8993ZM228.114 58.8396L228.119 58.8327C228.228 58.6826 228.38 58.3975 228.38 57.7493C228.38 56.7949 228.117 56.5269 227.831 56.3166L227.815 56.305L227.799 56.2933C227.174 55.8244 226.219 55.4459 224.635 55.4459H221.02V60.0527H224.372C225.538 60.0527 226.399 59.869 227.035 59.6078C227.65 59.3551 227.943 59.0772 228.109 58.8465L228.114 58.8396ZM224.372 64.1726C225.967 64.1726 227.377 63.9213 228.6 63.4188C229.824 62.9163 230.774 62.1953 231.451 61.2559C232.15 60.2946 232.5 59.1257 232.5 57.7493C232.5 55.6737 231.757 54.0898 230.271 52.9974C228.786 51.8831 226.907 51.326 224.635 51.326H216.901V64.1726H224.372Z" fill="#FAFAFA"/>
25
+<path d="M979.577 55.6468C979.388 53.2869 978.503 51.4461 976.922 50.1245C975.365 48.8029 972.993 48.1421 969.807 48.1421C967.777 48.1421 966.114 48.3899 964.816 48.8855C963.541 49.3575 962.597 50.0065 961.984 50.8325C961.37 51.6585 961.051 52.6025 961.028 53.6645C960.981 54.5377 961.134 55.3282 961.488 56.0362C961.866 56.7206 962.456 57.346 963.258 57.9124C964.06 58.4552 965.087 58.9508 966.338 59.3992C967.589 59.8476 969.075 60.2488 970.798 60.6028L976.745 61.8771C980.757 62.7267 984.191 63.8477 987.046 65.2401C989.902 66.6325 992.238 68.2726 994.056 70.1606C995.873 72.025 997.206 74.1253 998.056 76.4617C998.929 78.7981 999.377 81.3468 999.401 84.108C999.377 88.8751 998.185 92.9107 995.826 96.2146C993.466 99.5185 990.091 102.032 985.701 103.755C981.335 105.477 976.084 106.339 969.948 106.339C963.647 106.339 958.149 105.407 953.452 103.542C948.78 101.678 945.145 98.8106 942.549 94.9402C939.977 91.0463 938.679 86.0668 938.655 80.0017H957.346C957.464 82.22 958.019 84.0844 959.01 85.5948C960.001 87.1051 961.394 88.2497 963.187 89.0285C965.004 89.8073 967.164 90.1967 969.665 90.1967C971.766 90.1967 973.524 89.9371 974.94 89.4179C976.356 88.8987 977.43 88.1789 978.161 87.2585C978.893 86.3382 979.27 85.288 979.294 84.108C979.27 82.9988 978.905 82.0312 978.197 81.2052C977.512 80.3557 976.379 79.6005 974.798 78.9397C973.217 78.2553 971.081 77.6181 968.391 77.0281L961.169 75.4705C954.75 74.0781 949.688 71.7536 945.983 68.4968C942.302 65.2165 940.473 60.7444 940.496 55.0804C940.473 50.4785 941.7 46.4548 944.178 43.0092C946.679 39.5401 950.137 36.8379 954.55 34.9028C958.986 32.9676 964.072 32 969.807 32C975.66 32 980.722 32.9794 984.993 34.9382C989.265 36.8969 992.557 39.6581 994.87 43.2216C997.206 46.7616 998.386 50.9033 998.41 55.6468H979.577Z" fill="#FAFAFA"/>
26
+<path d="M870.211 105.489V32.9912H889.893V62.3019H890.884L912.69 32.9912H935.629L911.132 65.2755L936.195 105.489H912.69L896.406 78.3025L889.893 86.7983V105.489H870.211Z" fill="#FAFAFA"/>
27
+<path d="M803.624 105.489V32.9912H834.917C840.298 32.9912 845.006 33.9706 849.042 35.9293C853.077 37.8881 856.216 40.7083 858.458 44.3898C860.7 48.0714 861.821 52.4845 861.821 57.6292C861.821 62.8211 860.664 67.1988 858.352 70.7624C856.063 74.3259 852.841 77.0163 848.688 78.8335C844.558 80.6506 839.732 81.5592 834.209 81.5592H815.518V66.2667H830.245C832.557 66.2667 834.528 65.9835 836.156 65.4171C837.808 64.8271 839.071 63.8949 839.944 62.6205C840.841 61.3461 841.289 59.6824 841.289 57.6292C841.289 55.5524 840.841 53.8651 839.944 52.5671C839.071 51.2455 837.808 50.2779 836.156 49.6643C834.528 49.0271 832.557 48.7085 830.245 48.7085H823.306V105.489H803.624ZM846.103 72.2138L864.228 105.489H842.847L825.147 72.2138H846.103Z" fill="#FAFAFA"/>
28
+<path d="M794.518 69.2402C794.518 77.3113 792.948 84.1198 789.81 89.6657C786.671 95.188 782.435 99.377 777.101 102.233C771.768 105.064 765.82 106.48 759.26 106.48C752.652 106.48 746.681 105.053 741.348 102.197C736.038 99.318 731.813 95.1172 728.675 89.5949C725.56 84.049 724.002 77.2641 724.002 69.2402C724.002 61.1691 725.56 54.3725 728.675 48.8501C731.813 43.3042 736.038 39.1153 741.348 36.2833C746.681 33.4278 752.652 32 759.26 32C765.82 32 771.768 33.4278 777.101 36.2833C782.435 39.1153 786.671 43.3042 789.81 48.8501C792.948 54.3725 794.518 61.1691 794.518 69.2402ZM774.269 69.2402C774.269 64.8979 773.691 61.2399 772.535 58.2664C771.402 55.2692 769.714 53.0037 767.472 51.4697C765.254 49.9121 762.517 49.1333 759.26 49.1333C756.003 49.1333 753.254 49.9121 751.012 51.4697C748.793 53.0037 747.106 55.2692 745.95 58.2664C744.817 61.2399 744.25 64.8979 744.25 69.2402C744.25 73.5826 744.817 77.2523 745.95 80.2495C747.106 83.223 748.793 85.4886 751.012 87.0461C753.254 88.5801 756.003 89.3471 759.26 89.3471C762.517 89.3471 765.254 88.5801 767.472 87.0461C769.714 85.4886 771.402 83.223 772.535 80.2495C773.691 77.2523 774.269 73.5826 774.269 69.2402Z" fill="#FAFAFA"/>
29
+<path d="M638.238 105.489L616.857 32.9912H638.804L648.716 77.5945H649.283L661.035 32.9912H678.31L690.063 77.7361H690.629L700.541 32.9912H722.489L701.107 105.489H682.275L669.956 64.9923H669.389L657.07 105.489H638.238Z" fill="#FAFAFA"/>
30
+</g>
31
+<path d="M649.525 133.05H642.505V164.911H638.725V133.05H631.704V129.81H649.525V133.05Z" fill="#FAFAFA"/>
32
+<path d="M622.242 155.191H626.022V158.161C626.022 160.501 625.302 162.355 623.862 163.723C622.422 165.055 620.19 165.721 617.165 165.721C614.141 165.721 611.891 165.055 610.415 163.723C608.939 162.355 608.201 160.501 608.201 158.161V136.56C608.201 134.22 608.921 132.384 610.361 131.052C611.801 129.684 614.033 129 617.057 129C620.082 129 622.332 129.684 623.808 131.052C625.284 132.384 626.022 134.22 626.022 136.56V141.69H622.242V136.56C622.242 135.192 621.81 134.13 620.946 133.374C620.117 132.618 618.821 132.24 617.057 132.24C615.329 132.24 614.051 132.618 613.223 133.374C612.395 134.13 611.981 135.192 611.981 136.56V158.161C611.981 159.493 612.395 160.555 613.223 161.347C614.087 162.103 615.401 162.481 617.165 162.481C618.893 162.481 620.171 162.103 621 161.347C621.828 160.555 622.242 159.493 622.242 158.161V155.191Z" fill="#FAFAFA"/>
33
+<path d="M594.456 164.911V129.81H598.236V164.911H594.456Z" fill="#FAFAFA"/>
34
+<path d="M551.898 164.911V129.81H566.749V133.05H555.678V145.471H564.589V148.711H555.678V164.911H551.898ZM574.309 129.81H578.089V161.671H587.971V164.911H574.309V129.81Z" fill="#FAFAFA"/>
35
+<path d="M521.839 164.911V129.81H525.457L537.337 156.811V129.81H541.117V164.911H537.499L525.619 137.91V164.911H521.839Z" fill="#FAFAFA"/>
36
+<path d="M481.068 155.191H484.848V158.161C484.848 160.501 484.128 162.355 482.688 163.723C481.248 165.055 479.015 165.721 475.991 165.721C472.967 165.721 470.717 165.055 469.241 163.723C467.765 162.355 467.027 160.501 467.027 158.161V136.56C467.027 134.22 467.747 132.384 469.187 131.052C470.627 129.684 472.859 129 475.883 129C478.907 129 481.158 129.684 482.634 131.052C484.11 132.384 484.848 134.22 484.848 136.56V141.69H481.068V136.56C481.068 135.192 480.635 134.13 479.771 133.374C478.943 132.618 477.647 132.24 475.883 132.24C474.155 132.24 472.877 132.618 472.049 133.374C471.221 134.13 470.807 135.192 470.807 136.56V158.161C470.807 159.493 471.221 160.555 472.049 161.347C472.913 162.103 474.227 162.481 475.991 162.481C477.719 162.481 478.997 162.103 479.825 161.347C480.653 160.555 481.068 159.493 481.068 158.161V155.191Z" fill="#FAFAFA"/>
37
+<path d="M435.94 164.911V152.329L427.03 129.81H431.026L437.83 147.901L444.635 129.81H448.631L439.72 152.383V164.911H435.94Z" fill="#FAFAFA"/>
38
+<path d="M411.97 129.81C414.85 129.81 416.992 130.494 418.396 131.862C419.836 133.194 420.556 135.03 420.556 137.37V140.61C420.556 143.67 419.278 145.633 416.722 146.497C419.638 147.289 421.096 149.287 421.096 152.491V157.351C421.096 159.691 420.394 161.545 418.99 162.913C417.586 164.245 415.426 164.911 412.51 164.911H403.816V129.81H411.97ZM416.776 137.37C416.776 136.038 416.362 134.994 415.534 134.238C414.742 133.446 413.554 133.05 411.97 133.05H407.596V145.201H411.97C413.59 145.201 414.796 144.768 415.588 143.904C416.38 143.04 416.776 141.942 416.776 140.61V137.37ZM417.316 152.491C417.316 151.159 416.902 150.061 416.074 149.197C415.282 148.333 414.094 147.901 412.51 147.901H407.596V161.671H412.51C414.13 161.671 415.336 161.293 416.128 160.537C416.92 159.745 417.316 158.683 417.316 157.351V152.491Z" fill="#FAFAFA"/>
39
+<path d="M364.106 164.911V129.81H372.8C375.68 129.81 377.822 130.494 379.226 131.862C380.666 133.194 381.386 135.03 381.386 137.37V157.351C381.386 159.691 380.684 161.545 379.28 162.913C377.876 164.245 375.716 164.911 372.8 164.911H364.106ZM377.606 137.37C377.606 136.038 377.192 134.994 376.364 134.238C375.572 133.446 374.384 133.05 372.8 133.05H367.886V161.671H372.8C374.42 161.671 375.626 161.293 376.418 160.537C377.21 159.745 377.606 158.683 377.606 157.351V137.37Z" fill="#FAFAFA"/>
40
+<path d="M350.335 155.191H354.115V158.161C354.115 160.501 353.395 162.355 351.955 163.723C350.515 165.055 348.283 165.721 345.259 165.721C342.235 165.721 339.985 165.055 338.509 163.723C337.033 162.355 336.295 160.501 336.295 158.161V136.56C336.295 134.22 337.015 132.384 338.455 131.052C339.895 129.684 342.127 129 345.151 129C348.175 129 350.425 129.684 351.901 131.052C353.377 132.384 354.115 134.22 354.115 136.56V148.441H340.075V158.161C340.075 159.493 340.489 160.555 341.317 161.347C342.181 162.103 343.495 162.481 345.259 162.481C346.987 162.481 348.265 162.103 349.093 161.347C349.921 160.555 350.335 159.493 350.335 158.161V155.191ZM340.075 145.2H350.335V136.56C350.335 135.192 349.903 134.13 349.039 133.374C348.211 132.618 346.915 132.24 345.151 132.24C343.423 132.24 342.145 132.618 341.317 133.374C340.489 134.13 340.075 135.192 340.075 136.56V145.2Z" fill="#FAFAFA"/>
41
+<path d="M315.483 164.911V129.81H317.103L318.669 133.806C319.173 132.438 319.857 131.304 320.721 130.404C321.585 129.468 322.737 129 324.177 129C326.265 129 327.849 129.594 328.929 130.782C330.045 131.934 330.603 133.68 330.603 136.02V139.53H326.985V135.804C326.985 134.76 326.751 133.914 326.283 133.266C325.815 132.582 325.059 132.24 324.015 132.24C323.331 132.24 322.611 132.51 321.855 133.05C321.135 133.59 320.523 134.454 320.019 135.642C319.515 136.794 319.263 138.324 319.263 140.232V164.911H315.483Z" fill="#FAFAFA"/>
42
+<path d="M301.713 155.191H305.493V158.161C305.493 160.501 304.773 162.355 303.333 163.723C301.893 165.055 299.661 165.721 296.636 165.721C293.612 165.721 291.362 165.055 289.886 163.723C288.41 162.355 287.672 160.501 287.672 158.161V136.56C287.672 134.22 288.392 132.384 289.832 131.052C291.272 129.684 293.504 129 296.528 129C299.553 129 301.803 129.684 303.279 131.052C304.755 132.384 305.493 134.22 305.493 136.56V148.441H291.452V158.161C291.452 159.493 291.866 160.555 292.694 161.347C293.558 162.103 294.872 162.481 296.636 162.481C298.365 162.481 299.643 162.103 300.471 161.347C301.299 160.555 301.713 159.493 301.713 158.161V155.191ZM291.452 145.2H301.713V136.56C301.713 135.192 301.281 134.13 300.417 133.374C299.589 132.618 298.293 132.24 296.528 132.24C294.8 132.24 293.522 132.618 292.694 133.374C291.866 134.13 291.452 135.192 291.452 136.56V145.2Z" fill="#FAFAFA"/>
43
+<path d="M260.684 129.81H264.464V158.161C264.464 159.493 264.878 160.555 265.706 161.347C266.534 162.103 267.776 162.481 269.432 162.481C271.124 162.481 272.384 162.103 273.212 161.347C274.04 160.555 274.454 159.493 274.454 158.161V129.81H278.235V158.161C278.235 160.501 277.479 162.355 275.966 163.723C274.49 165.055 272.312 165.721 269.432 165.721C267.776 165.721 266.39 165.433 265.274 164.857C264.158 164.281 263.258 163.525 262.574 162.589C261.926 163.561 261.026 164.335 259.874 164.911C258.722 165.451 257.336 165.721 255.716 165.721C252.836 165.721 250.64 165.055 249.128 163.723C247.652 162.355 246.914 160.501 246.914 158.161V129.81H250.694V158.161C250.694 159.493 251.108 160.555 251.936 161.347C252.8 162.103 254.06 162.481 255.716 162.481C257.408 162.481 258.65 162.103 259.442 161.347C260.27 160.555 260.684 159.493 260.684 158.161V129.81Z" fill="#FAFAFA"/>
44
+<path d="M237.476 158.161C237.476 160.501 236.72 162.355 235.208 163.723C233.732 165.055 231.446 165.721 228.35 165.721C225.254 165.721 222.932 165.055 221.383 163.723C219.871 162.355 219.115 160.501 219.115 158.161V136.56C219.115 134.22 219.853 132.384 221.329 131.052C222.842 129.684 225.146 129 228.242 129C231.338 129 233.642 129.684 235.154 131.052C236.702 132.384 237.476 134.22 237.476 136.56V158.161ZM233.696 136.56C233.696 135.192 233.246 134.13 232.346 133.374C231.446 132.618 230.078 132.24 228.242 132.24C226.442 132.24 225.092 132.618 224.192 133.374C223.328 134.13 222.896 135.192 222.896 136.56V158.161C222.896 159.493 223.346 160.555 224.246 161.347C225.146 162.103 226.514 162.481 228.35 162.481C230.15 162.481 231.482 162.103 232.346 161.347C233.246 160.555 233.696 159.493 233.696 158.161V136.56Z" fill="#FAFAFA"/>
45
+<path d="M194.56 164.911V129.81H202.714C205.594 129.81 207.736 130.494 209.14 131.862C210.58 133.194 211.3 135.03 211.3 137.37V146.551C211.3 148.891 210.598 150.745 209.194 152.113C207.79 153.445 205.63 154.111 202.714 154.111H198.34V164.911H194.56ZM207.52 137.37C207.52 136.038 207.106 134.994 206.278 134.238C205.486 133.446 204.298 133.05 202.714 133.05H198.34V150.871H202.714C204.334 150.871 205.54 150.493 206.332 149.737C207.124 148.945 207.52 147.883 207.52 146.551V137.37Z" fill="#FAFAFA"/>
46
+</g>
47
+<defs>
48
+<filter id="filter0_d_303_614" x="0" y="0" width="1033" height="213" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
49
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
50
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
51
+<feOffset dy="4"/>
52
+<feGaussianBlur stdDeviation="2"/>
53
+<feComposite in2="hardAlpha" operator="out"/>
54
+<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
55
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/>
56
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/>
57
+</filter>
58
+<filter id="filter1_d_303_614" x="190.56" y="32" width="812.841" height="85.1917" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
59
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
60
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
61
+<feOffset dy="4"/>
62
+<feGaussianBlur stdDeviation="2"/>
63
+<feComposite in2="hardAlpha" operator="out"/>
64
+<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
65
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/>
66
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/>
67
+</filter>
68
+<linearGradient id="paint0_linear_303_614" x1="48.4373" y1="83.8162" x2="111.888" y2="65.7367" gradientUnits="userSpaceOnUse">
69
+<stop stop-color="#DD4652"/>
70
+<stop offset="0.15" stop-color="#DD4854"/>
71
+<stop offset="0.23" stop-color="#DE4F5B"/>
72
+<stop offset="0.29" stop-color="#E15C67"/>
73
+<stop offset="0.34" stop-color="#E46F78"/>
74
+<stop offset="0.38" stop-color="#E9878F"/>
75
+<stop offset="0.42" stop-color="#EEA5AB"/>
76
+<stop offset="0.46" stop-color="#F5C8CC"/>
77
+<stop offset="0.49" stop-color="#FCF0F1"/>
78
+<stop offset="0.5" stop-color="#FEFDFD"/>
79
+<stop offset="0.52" stop-color="#F7D4D8"/>
80
+<stop offset="0.55" stop-color="#F1B0B7"/>
81
+<stop offset="0.58" stop-color="#EB8F9A"/>
82
+<stop offset="0.61" stop-color="#E67481"/>
83
+<stop offset="0.64" stop-color="#E25E6D"/>
84
+<stop offset="0.68" stop-color="#DF4D5E"/>
85
+<stop offset="0.73" stop-color="#DD4153"/>
86
+<stop offset="0.8" stop-color="#DC3A4D"/>
87
+<stop offset="1" stop-color="#DC394C"/>
88
+</linearGradient>
89
+<linearGradient id="paint1_linear_303_614" x1="107.569" y1="12.2053" x2="11.6611" y2="39.2106" gradientUnits="userSpaceOnUse">
90
+<stop stop-color="#8B3138" stop-opacity="0"/>
91
+<stop offset="0.65" stop-color="#DC394C"/>
92
+</linearGradient>
93
+<linearGradient id="paint2_linear_303_614" x1="107.569" y1="189.634" x2="11.6864" y2="162.629" gradientUnits="userSpaceOnUse">
94
+<stop stop-color="#8B3138" stop-opacity="0"/>
95
+<stop offset="0.65" stop-color="#DC394C"/>
96
+</linearGradient>
97
+<linearGradient id="paint3_linear_303_614" x1="30.5598" y1="131.944" x2="125.396" y2="131.944" gradientUnits="userSpaceOnUse">
98
+<stop stop-color="#8B3138" stop-opacity="0"/>
99
+<stop offset="0.65" stop-color="#DC394C"/>
100
+</linearGradient>
101
+<linearGradient id="paint4_linear_303_614" x1="30.5847" y1="69.8189" x2="125.396" y2="69.8189" gradientUnits="userSpaceOnUse">
102
+<stop stop-color="#8B3138" stop-opacity="0"/>
103
+<stop offset="0.65" stop-color="#DC394C"/>
104
+</linearGradient>
105
+<linearGradient id="paint5_linear_303_614" x1="-48.1491" y1="140.528" x2="123.546" y2="139.131" gradientUnits="userSpaceOnUse">
106
+<stop stop-color="#6A1921"/>
107
+<stop offset="1" stop-color="#DC394C"/>
108
+</linearGradient>
109
+</defs>
110
+</svg>
--- a/static/admin/img/logo-dark.svg
+++ b/static/admin/img/logo-dark.svg
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/static/admin/img/logo-dark.svg
+++ b/static/admin/img/logo-dark.svg
@@ -0,0 +1,110 @@
1 <svg width="1033" height="213" viewBox="0 0 1033 213" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <g filter="url(#filter0_d_303_614)">
3 <rect x="4" width="1025" height="205" fill="#2B2D2C"/>
4 <path d="M491.56 167H500.064V163.116H495.59V132.884H500.138V129H491.56V167Z" fill="#DB394C"/>
5 <path d="M506.018 129V132.884H510.529V163.116H505.981V167H514.56V129H506.018Z" fill="#DB394C"/>
6 <path d="M505.093 139.932H501.026V155.788H505.093V139.932Z" fill="#DB394C"/>
7 <path d="M148.624 101.062C168.554 83.5327 166.323 44.969 144.11 30.1725C134.17 22.9805 119.767 19.0365 107.519 19.1396C101.434 19.6293 95.7028 22.4134 90.7074 26.048C84.6217 30.5592 79.7024 35.9209 72.9573 40.2774C65.1219 44.969 54.1422 50.4597 44.0754 49.2223C37.3303 47.3921 37.1782 40.8703 37.4825 34.9929C37.4825 34.9414 37.4825 34.8641 37.4825 34.8125C37.2796 34.8125 37.1275 34.8641 36.9246 34.8641C34.4903 34.8641 32.4871 34.1681 30.8896 33.0854C30.8135 33.7041 30.7374 34.3485 30.7374 34.9929C29.6724 46.129 34.2367 55.873 46.2814 56.3628C61.9776 56.5175 77.0145 46.7734 88.0956 36.9005C97.5793 27.9556 106.125 23.3156 119.006 26.7956C132.522 29.1929 144.871 36.1014 151.311 48.6294C160.034 65.849 155.216 92.9158 136.477 101.01C155.216 109.079 160.034 136.171 151.311 153.391C144.871 165.919 132.395 172.518 118.879 174.915C105.947 178.395 97.63 174.09 88.0956 165.12C77.0145 155.247 61.9523 145.503 46.2814 145.657C34.2367 146.199 29.6724 155.865 30.7374 167.053C30.7374 173.523 34.5917 179.117 40.0943 181.54C40.0182 180.354 40.0943 179.194 40.3986 178.112C40.7536 176.849 41.2861 175.766 41.9454 174.812C39.2575 173.265 37.4317 170.378 37.4317 167.053C37.1275 161.201 37.2542 154.654 44.0246 152.824C54.0661 151.586 65.0459 157.077 72.9066 161.768C79.6517 166.125 84.571 171.513 90.6567 175.998C95.6521 179.633 101.383 182.417 107.469 182.906C119.716 183.009 134.119 179.065 144.059 171.873C166.272 157.103 168.529 118.539 148.573 100.984L148.624 101.062Z" fill="#DC394C"/>
8 <path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="#DC394C"/>
9 <g opacity="0.2">
10 <path d="M114.366 59.0179C120.604 62.6268 119.59 76.882 114.848 80.1815C114.62 80.362 114.341 80.4909 114.087 80.6455C115.685 82.3727 116.724 84.5638 117.206 86.8322C122.43 83.5069 125.523 77.81 125.397 69.0713C125.955 44.8143 104.35 49.4801 87.7157 49.2739C86.3464 49.2739 84.9771 49.6348 83.8107 50.4081C80.6157 52.5477 76.9135 54.3521 73.2114 56.0535C82.7711 56.9557 106.227 53.5015 114.341 59.0179H114.366Z" fill="url(#paint0_linear_303_614)"/>
11 </g>
12 <path d="M37.7352 41.4374C37.4056 39.3236 37.4563 37.0294 37.5577 34.8382C37.5577 29.8889 41.5135 25.8676 46.3821 25.8676H79.5241C80.158 25.8676 80.7412 25.6356 81.223 25.2231C83.6827 23.1093 86.8523 20.7893 89.8445 19.0364H46.4074C37.8367 19.0364 30.8634 26.1253 30.8634 34.8382C30.483 38.7823 30.8381 42.5716 31.9538 45.8196C33.3991 43.7574 35.4277 42.1076 37.7606 41.4632L37.7352 41.4374Z" fill="url(#paint1_linear_303_614)"/>
13 <path d="M37.7352 160.402C37.4056 162.516 37.4563 164.81 37.5577 167.001C37.5577 171.951 41.5135 175.972 46.3821 175.972H79.5241C80.158 175.972 80.7412 176.204 81.223 176.617C83.6827 178.73 86.8523 181.05 89.8445 182.803H46.4074C37.8367 182.803 30.8634 175.714 30.8634 167.001C30.483 163.057 30.8381 159.268 31.9538 156.02C33.3991 158.082 35.4277 159.732 37.7606 160.376L37.7352 160.402Z" fill="url(#paint2_linear_303_614)"/>
14 <path d="M102.042 111.167C102.042 111.167 35.8849 111.167 30.5852 111.167V117.998H102.042C107.494 117.998 111.779 119.183 114.848 121.555C119.615 124.88 120.604 139.11 114.366 142.719C106.48 148.209 82.0859 144.781 72.8051 145.683C76.5833 147.436 80.3616 149.292 83.6327 151.483C84.5962 152.128 85.712 152.488 86.853 152.463C103.437 152.179 125.929 157.129 125.396 132.665C125.599 117.276 115.938 111.321 102.042 111.167Z" fill="url(#paint3_linear_303_614)"/>
15 <path d="M87.7147 49.2997C86.3454 49.2997 84.9761 49.6606 83.8097 50.4339C80.6146 52.5735 76.9125 54.3779 73.2103 56.0793C82.77 56.9815 106.226 53.5273 114.34 59.0437C120.578 62.6526 119.563 76.9078 114.822 80.2074C111.753 82.5531 107.468 83.7647 102.016 83.7647H30.5593V90.5958H102.016C115.912 90.4669 125.573 84.4865 125.37 69.0971C125.928 44.8401 104.324 49.5059 87.6893 49.2997H87.7147Z" fill="url(#paint4_linear_303_614)"/>
16 <path d="M121.263 126.427C120.198 126.608 119.133 126.814 118.068 127.072C119.539 132.614 118.702 140.218 114.391 142.744C106.505 148.235 82.1114 144.807 72.8306 145.709C76.6088 147.462 80.3871 149.318 83.6582 151.509C84.6217 152.153 85.7375 152.514 86.8785 152.489C103.462 152.205 125.954 157.154 125.422 132.691C125.447 130.062 125.168 127.768 124.661 125.654C123.596 126.04 122.48 126.298 121.288 126.427H121.263Z" fill="url(#paint5_linear_303_614)"/>
17 <g filter="url(#filter1_d_303_614)">
18 <rect x="195.06" y="32.5" width="803.841" height="76.1916" stroke="#2B2D2C" shape-rendering="crispEdges"/>
19 <path fill-rule="evenodd" clip-rule="evenodd" d="M549.696 108.274V32.9176H582.786C588.268 32.9176 593.264 33.9167 597.661 36.0512C602.078 38.1951 605.594 41.3351 608.098 45.4472C610.637 49.6163 611.813 54.4753 611.813 59.8467C611.813 65.2685 610.6 70.1409 607.941 74.2414C606.304 76.7879 604.248 78.9243 601.812 80.6522L616.857 108.274H587.652L576.157 86.6632V108.274H549.696ZM590.127 104.154H609.921L596.291 79.1301C597.57 78.5163 598.748 77.8075 599.824 77.0036C601.677 75.6192 603.23 73.9531 604.481 72.005C606.622 68.706 607.693 64.6532 607.693 59.8467C607.693 55.0838 606.655 50.9983 604.579 47.59C602.504 44.1817 599.598 41.5709 595.862 39.7576C592.126 37.9442 587.768 37.0375 582.786 37.0375H553.816V104.154H572.037V82.0004H578.343L590.127 104.154ZM572.037 67.843H578.46C580.601 67.843 582.426 67.5808 583.933 67.0565C585.463 66.5103 586.631 65.6473 587.44 64.4675C588.27 63.2877 588.685 61.7474 588.685 59.8467C588.685 57.9241 588.27 56.3619 587.44 55.1603C586.631 53.9368 585.463 53.0411 583.933 52.473C582.426 51.8831 580.601 51.5882 578.46 51.5882H572.037V67.843ZM576.157 55.7081V63.7231H578.46C580.277 63.7231 581.612 63.499 582.563 63.171C583.38 62.8765 583.79 62.5051 584.041 62.1388L584.056 62.1176L584.071 62.0965C584.278 61.8011 584.565 61.1655 584.565 59.8467C584.565 58.4992 584.275 57.8278 584.05 57.5022L584.026 57.467L584.002 57.4314C583.73 57.019 583.305 56.6347 582.499 56.3351L582.465 56.3227L582.432 56.3096C581.53 55.9566 580.244 55.7081 578.46 55.7081H576.157Z" fill="#FAFAFA"/>
20 <path fill-rule="evenodd" clip-rule="evenodd" d="M475.677 108.274V32.9176H532.288V55.8391H502.138V59.1351H529.797V82.0566H502.138V85.3525H532.157V108.274H475.677ZM498.018 89.4724V77.9367H525.677V63.2549H498.018V51.7193H528.168V37.0375H479.797V104.154H528.037V89.4724H498.018Z" fill="#FAFAFA"/>
21 <path fill-rule="evenodd" clip-rule="evenodd" d="M406.344 108.274V32.9176H432.805V85.3525H459.94V108.274H406.344ZM428.685 89.4724V37.0375H410.464V104.154H455.82V89.4724H428.685Z" fill="#FAFAFA"/>
22 <path fill-rule="evenodd" clip-rule="evenodd" d="M389.337 32.9176V108.274H362.876V32.9176H389.337ZM385.217 37.0375H366.996V104.154H385.217V37.0375Z" fill="#FAFAFA"/>
23 <path fill-rule="evenodd" clip-rule="evenodd" d="M341.181 91.5344L341.177 91.5409C337.907 97.2938 333.433 101.742 327.775 104.771L327.768 104.775L327.762 104.778C322.179 107.742 316.001 109.192 309.313 109.192C302.581 109.192 296.376 107.731 290.786 104.738L290.776 104.733L290.767 104.728C285.138 101.676 280.682 97.2211 277.416 91.4754L277.411 91.4663L277.406 91.4573C274.097 85.5658 272.552 78.5492 272.552 70.5958C272.552 62.6044 274.095 55.5718 277.41 49.6951L277.413 49.69C280.679 43.9181 285.143 39.4637 290.789 36.4517C296.378 33.46 302.582 32 309.313 32C316.004 32 322.185 33.4617 327.769 36.4501C333.435 39.4604 337.911 43.9139 341.18 49.6886C344.519 55.5668 346.074 62.602 346.074 70.5958C346.074 78.5928 344.518 85.6372 341.181 91.5344ZM325.83 40.0853C320.892 37.4417 315.387 36.1199 309.313 36.1199C303.196 36.1199 297.668 37.4417 292.731 40.0853C287.815 42.707 283.904 46.585 280.998 51.7193C278.114 56.8317 276.672 63.1239 276.672 70.5958C276.672 78.0241 278.114 84.3054 280.998 89.4396C283.904 94.552 287.815 98.4409 292.731 101.106C297.668 103.75 303.196 105.072 309.313 105.072C315.387 105.072 320.892 103.761 325.83 101.139C330.768 98.4956 334.689 94.6176 337.595 89.5052C340.501 84.3709 341.954 78.0678 341.954 70.5958C341.954 63.1239 340.501 56.8317 337.595 51.7193C334.689 46.585 330.768 42.707 325.83 40.0853ZM317.756 61.9115L317.749 61.8931C316.943 59.7622 315.851 58.4077 314.59 57.5445L314.569 57.5305L314.549 57.5161C313.319 56.6524 311.655 56.1013 309.313 56.1013C306.973 56.1013 305.289 56.6519 304.028 57.5279L304.02 57.533C302.77 58.3979 301.668 59.7627 300.838 61.9119C300.021 64.0587 299.538 66.9113 299.538 70.5958C299.538 74.2732 300.019 77.1389 300.838 79.3129C301.662 81.4232 302.759 82.7991 304.024 83.6938C305.285 84.5503 306.973 85.0903 309.313 85.0903C311.665 85.0903 313.337 84.545 314.569 83.6941C315.854 82.8 316.95 81.4286 317.753 79.3212L317.756 79.313L317.759 79.3048C318.595 77.1376 319.088 74.2759 319.088 70.5958C319.088 66.9141 318.594 64.0686 317.763 61.9298L317.756 61.9115ZM316.916 87.08C314.862 88.5002 312.328 89.2102 309.313 89.2102C306.298 89.2102 303.753 88.5002 301.677 87.08C299.624 85.6381 298.061 83.5407 296.991 80.7879C295.942 78.0132 295.418 74.6158 295.418 70.5958C295.418 66.5758 295.942 63.1894 296.991 60.4366C298.061 57.6619 299.624 55.5645 301.677 54.1444C303.753 52.7024 306.298 51.9814 309.313 51.9814C312.328 51.9814 314.862 52.7024 316.916 54.1444C318.992 55.5645 320.554 57.6619 321.602 60.4366C322.673 63.1894 323.208 66.5758 323.208 70.5958C323.208 74.6158 322.673 78.0132 321.602 80.7879C320.554 83.5407 318.992 85.6381 316.916 87.08Z" fill="#FAFAFA"/>
24 <path fill-rule="evenodd" clip-rule="evenodd" d="M194.56 108.274V32.9176H227.781C233.232 32.9176 238.075 33.6472 242.173 35.2702C246.242 36.8731 249.59 39.253 251.916 42.5486C254.255 45.8309 255.365 49.6469 255.365 53.8167C255.365 56.9112 254.675 59.8338 253.244 62.5031C252.104 64.6623 250.581 66.5265 248.711 68.0855C251.447 69.7483 253.658 71.9966 255.309 74.7798C257.146 77.8531 257.987 81.3596 257.987 85.1465C257.987 89.576 256.814 93.6497 254.409 97.2336C252.037 100.802 248.706 103.516 244.595 105.431C240.412 107.379 235.595 108.274 230.271 108.274H194.56ZM244.445 66.1032C244.662 65.967 244.875 65.8259 245.084 65.68C247.029 64.3036 248.536 62.5995 249.607 60.5676C250.699 58.5358 251.245 56.2855 251.245 53.8167C251.245 50.4084 250.35 47.448 248.558 44.9355C246.788 42.423 244.156 40.4785 240.66 39.1021C237.186 37.7257 232.893 37.0375 227.781 37.0375H198.679V104.154H230.271C235.143 104.154 239.338 103.335 242.856 101.696C246.373 100.058 249.082 97.8073 250.983 94.9453C252.906 92.0832 253.867 88.8169 253.867 85.1465C253.867 81.9786 253.168 79.2257 251.77 76.888C250.371 74.5284 248.493 72.6823 246.133 71.3496C245.682 71.0907 245.222 70.8546 244.752 70.6413C242.8 69.7553 240.683 69.2613 238.401 69.1594C238.357 69.1574 238.312 69.1556 238.268 69.1539V68.4984C238.276 68.4967 238.284 68.4951 238.293 68.4934C240.602 68.0248 242.653 67.2281 244.445 66.1032ZM228.809 80.0833L228.797 80.0783C228.133 79.7907 227.138 79.5659 225.683 79.5659H221.02V85.4836H225.421C228.065 85.4836 229.319 84.9764 229.832 84.6027C230.143 84.3635 230.477 83.9794 230.477 82.7869C230.477 81.8557 230.271 81.3515 230.077 81.0582C229.849 80.7117 229.491 80.3743 228.82 80.0881L228.809 80.0833ZM232.303 87.8993C230.774 89.0354 228.48 89.6035 225.421 89.6035H216.901V75.446H225.683C227.54 75.446 229.124 75.7301 230.435 76.2981C231.768 76.8662 232.795 77.6964 233.516 78.7888C234.237 79.8812 234.597 81.2139 234.597 82.7869C234.597 85.0373 233.833 86.7414 232.303 87.8993ZM228.114 58.8396L228.119 58.8327C228.228 58.6826 228.38 58.3975 228.38 57.7493C228.38 56.7949 228.117 56.5269 227.831 56.3166L227.815 56.305L227.799 56.2933C227.174 55.8244 226.219 55.4459 224.635 55.4459H221.02V60.0527H224.372C225.538 60.0527 226.399 59.869 227.035 59.6078C227.65 59.3551 227.943 59.0772 228.109 58.8465L228.114 58.8396ZM224.372 64.1726C225.967 64.1726 227.377 63.9213 228.6 63.4188C229.824 62.9163 230.774 62.1953 231.451 61.2559C232.15 60.2946 232.5 59.1257 232.5 57.7493C232.5 55.6737 231.757 54.0898 230.271 52.9974C228.786 51.8831 226.907 51.326 224.635 51.326H216.901V64.1726H224.372Z" fill="#FAFAFA"/>
25 <path d="M979.577 55.6468C979.388 53.2869 978.503 51.4461 976.922 50.1245C975.365 48.8029 972.993 48.1421 969.807 48.1421C967.777 48.1421 966.114 48.3899 964.816 48.8855C963.541 49.3575 962.597 50.0065 961.984 50.8325C961.37 51.6585 961.051 52.6025 961.028 53.6645C960.981 54.5377 961.134 55.3282 961.488 56.0362C961.866 56.7206 962.456 57.346 963.258 57.9124C964.06 58.4552 965.087 58.9508 966.338 59.3992C967.589 59.8476 969.075 60.2488 970.798 60.6028L976.745 61.8771C980.757 62.7267 984.191 63.8477 987.046 65.2401C989.902 66.6325 992.238 68.2726 994.056 70.1606C995.873 72.025 997.206 74.1253 998.056 76.4617C998.929 78.7981 999.377 81.3468 999.401 84.108C999.377 88.8751 998.185 92.9107 995.826 96.2146C993.466 99.5185 990.091 102.032 985.701 103.755C981.335 105.477 976.084 106.339 969.948 106.339C963.647 106.339 958.149 105.407 953.452 103.542C948.78 101.678 945.145 98.8106 942.549 94.9402C939.977 91.0463 938.679 86.0668 938.655 80.0017H957.346C957.464 82.22 958.019 84.0844 959.01 85.5948C960.001 87.1051 961.394 88.2497 963.187 89.0285C965.004 89.8073 967.164 90.1967 969.665 90.1967C971.766 90.1967 973.524 89.9371 974.94 89.4179C976.356 88.8987 977.43 88.1789 978.161 87.2585C978.893 86.3382 979.27 85.288 979.294 84.108C979.27 82.9988 978.905 82.0312 978.197 81.2052C977.512 80.3557 976.379 79.6005 974.798 78.9397C973.217 78.2553 971.081 77.6181 968.391 77.0281L961.169 75.4705C954.75 74.0781 949.688 71.7536 945.983 68.4968C942.302 65.2165 940.473 60.7444 940.496 55.0804C940.473 50.4785 941.7 46.4548 944.178 43.0092C946.679 39.5401 950.137 36.8379 954.55 34.9028C958.986 32.9676 964.072 32 969.807 32C975.66 32 980.722 32.9794 984.993 34.9382C989.265 36.8969 992.557 39.6581 994.87 43.2216C997.206 46.7616 998.386 50.9033 998.41 55.6468H979.577Z" fill="#FAFAFA"/>
26 <path d="M870.211 105.489V32.9912H889.893V62.3019H890.884L912.69 32.9912H935.629L911.132 65.2755L936.195 105.489H912.69L896.406 78.3025L889.893 86.7983V105.489H870.211Z" fill="#FAFAFA"/>
27 <path d="M803.624 105.489V32.9912H834.917C840.298 32.9912 845.006 33.9706 849.042 35.9293C853.077 37.8881 856.216 40.7083 858.458 44.3898C860.7 48.0714 861.821 52.4845 861.821 57.6292C861.821 62.8211 860.664 67.1988 858.352 70.7624C856.063 74.3259 852.841 77.0163 848.688 78.8335C844.558 80.6506 839.732 81.5592 834.209 81.5592H815.518V66.2667H830.245C832.557 66.2667 834.528 65.9835 836.156 65.4171C837.808 64.8271 839.071 63.8949 839.944 62.6205C840.841 61.3461 841.289 59.6824 841.289 57.6292C841.289 55.5524 840.841 53.8651 839.944 52.5671C839.071 51.2455 837.808 50.2779 836.156 49.6643C834.528 49.0271 832.557 48.7085 830.245 48.7085H823.306V105.489H803.624ZM846.103 72.2138L864.228 105.489H842.847L825.147 72.2138H846.103Z" fill="#FAFAFA"/>
28 <path d="M794.518 69.2402C794.518 77.3113 792.948 84.1198 789.81 89.6657C786.671 95.188 782.435 99.377 777.101 102.233C771.768 105.064 765.82 106.48 759.26 106.48C752.652 106.48 746.681 105.053 741.348 102.197C736.038 99.318 731.813 95.1172 728.675 89.5949C725.56 84.049 724.002 77.2641 724.002 69.2402C724.002 61.1691 725.56 54.3725 728.675 48.8501C731.813 43.3042 736.038 39.1153 741.348 36.2833C746.681 33.4278 752.652 32 759.26 32C765.82 32 771.768 33.4278 777.101 36.2833C782.435 39.1153 786.671 43.3042 789.81 48.8501C792.948 54.3725 794.518 61.1691 794.518 69.2402ZM774.269 69.2402C774.269 64.8979 773.691 61.2399 772.535 58.2664C771.402 55.2692 769.714 53.0037 767.472 51.4697C765.254 49.9121 762.517 49.1333 759.26 49.1333C756.003 49.1333 753.254 49.9121 751.012 51.4697C748.793 53.0037 747.106 55.2692 745.95 58.2664C744.817 61.2399 744.25 64.8979 744.25 69.2402C744.25 73.5826 744.817 77.2523 745.95 80.2495C747.106 83.223 748.793 85.4886 751.012 87.0461C753.254 88.5801 756.003 89.3471 759.26 89.3471C762.517 89.3471 765.254 88.5801 767.472 87.0461C769.714 85.4886 771.402 83.223 772.535 80.2495C773.691 77.2523 774.269 73.5826 774.269 69.2402Z" fill="#FAFAFA"/>
29 <path d="M638.238 105.489L616.857 32.9912H638.804L648.716 77.5945H649.283L661.035 32.9912H678.31L690.063 77.7361H690.629L700.541 32.9912H722.489L701.107 105.489H682.275L669.956 64.9923H669.389L657.07 105.489H638.238Z" fill="#FAFAFA"/>
30 </g>
31 <path d="M649.525 133.05H642.505V164.911H638.725V133.05H631.704V129.81H649.525V133.05Z" fill="#FAFAFA"/>
32 <path d="M622.242 155.191H626.022V158.161C626.022 160.501 625.302 162.355 623.862 163.723C622.422 165.055 620.19 165.721 617.165 165.721C614.141 165.721 611.891 165.055 610.415 163.723C608.939 162.355 608.201 160.501 608.201 158.161V136.56C608.201 134.22 608.921 132.384 610.361 131.052C611.801 129.684 614.033 129 617.057 129C620.082 129 622.332 129.684 623.808 131.052C625.284 132.384 626.022 134.22 626.022 136.56V141.69H622.242V136.56C622.242 135.192 621.81 134.13 620.946 133.374C620.117 132.618 618.821 132.24 617.057 132.24C615.329 132.24 614.051 132.618 613.223 133.374C612.395 134.13 611.981 135.192 611.981 136.56V158.161C611.981 159.493 612.395 160.555 613.223 161.347C614.087 162.103 615.401 162.481 617.165 162.481C618.893 162.481 620.171 162.103 621 161.347C621.828 160.555 622.242 159.493 622.242 158.161V155.191Z" fill="#FAFAFA"/>
33 <path d="M594.456 164.911V129.81H598.236V164.911H594.456Z" fill="#FAFAFA"/>
34 <path d="M551.898 164.911V129.81H566.749V133.05H555.678V145.471H564.589V148.711H555.678V164.911H551.898ZM574.309 129.81H578.089V161.671H587.971V164.911H574.309V129.81Z" fill="#FAFAFA"/>
35 <path d="M521.839 164.911V129.81H525.457L537.337 156.811V129.81H541.117V164.911H537.499L525.619 137.91V164.911H521.839Z" fill="#FAFAFA"/>
36 <path d="M481.068 155.191H484.848V158.161C484.848 160.501 484.128 162.355 482.688 163.723C481.248 165.055 479.015 165.721 475.991 165.721C472.967 165.721 470.717 165.055 469.241 163.723C467.765 162.355 467.027 160.501 467.027 158.161V136.56C467.027 134.22 467.747 132.384 469.187 131.052C470.627 129.684 472.859 129 475.883 129C478.907 129 481.158 129.684 482.634 131.052C484.11 132.384 484.848 134.22 484.848 136.56V141.69H481.068V136.56C481.068 135.192 480.635 134.13 479.771 133.374C478.943 132.618 477.647 132.24 475.883 132.24C474.155 132.24 472.877 132.618 472.049 133.374C471.221 134.13 470.807 135.192 470.807 136.56V158.161C470.807 159.493 471.221 160.555 472.049 161.347C472.913 162.103 474.227 162.481 475.991 162.481C477.719 162.481 478.997 162.103 479.825 161.347C480.653 160.555 481.068 159.493 481.068 158.161V155.191Z" fill="#FAFAFA"/>
37 <path d="M435.94 164.911V152.329L427.03 129.81H431.026L437.83 147.901L444.635 129.81H448.631L439.72 152.383V164.911H435.94Z" fill="#FAFAFA"/>
38 <path d="M411.97 129.81C414.85 129.81 416.992 130.494 418.396 131.862C419.836 133.194 420.556 135.03 420.556 137.37V140.61C420.556 143.67 419.278 145.633 416.722 146.497C419.638 147.289 421.096 149.287 421.096 152.491V157.351C421.096 159.691 420.394 161.545 418.99 162.913C417.586 164.245 415.426 164.911 412.51 164.911H403.816V129.81H411.97ZM416.776 137.37C416.776 136.038 416.362 134.994 415.534 134.238C414.742 133.446 413.554 133.05 411.97 133.05H407.596V145.201H411.97C413.59 145.201 414.796 144.768 415.588 143.904C416.38 143.04 416.776 141.942 416.776 140.61V137.37ZM417.316 152.491C417.316 151.159 416.902 150.061 416.074 149.197C415.282 148.333 414.094 147.901 412.51 147.901H407.596V161.671H412.51C414.13 161.671 415.336 161.293 416.128 160.537C416.92 159.745 417.316 158.683 417.316 157.351V152.491Z" fill="#FAFAFA"/>
39 <path d="M364.106 164.911V129.81H372.8C375.68 129.81 377.822 130.494 379.226 131.862C380.666 133.194 381.386 135.03 381.386 137.37V157.351C381.386 159.691 380.684 161.545 379.28 162.913C377.876 164.245 375.716 164.911 372.8 164.911H364.106ZM377.606 137.37C377.606 136.038 377.192 134.994 376.364 134.238C375.572 133.446 374.384 133.05 372.8 133.05H367.886V161.671H372.8C374.42 161.671 375.626 161.293 376.418 160.537C377.21 159.745 377.606 158.683 377.606 157.351V137.37Z" fill="#FAFAFA"/>
40 <path d="M350.335 155.191H354.115V158.161C354.115 160.501 353.395 162.355 351.955 163.723C350.515 165.055 348.283 165.721 345.259 165.721C342.235 165.721 339.985 165.055 338.509 163.723C337.033 162.355 336.295 160.501 336.295 158.161V136.56C336.295 134.22 337.015 132.384 338.455 131.052C339.895 129.684 342.127 129 345.151 129C348.175 129 350.425 129.684 351.901 131.052C353.377 132.384 354.115 134.22 354.115 136.56V148.441H340.075V158.161C340.075 159.493 340.489 160.555 341.317 161.347C342.181 162.103 343.495 162.481 345.259 162.481C346.987 162.481 348.265 162.103 349.093 161.347C349.921 160.555 350.335 159.493 350.335 158.161V155.191ZM340.075 145.2H350.335V136.56C350.335 135.192 349.903 134.13 349.039 133.374C348.211 132.618 346.915 132.24 345.151 132.24C343.423 132.24 342.145 132.618 341.317 133.374C340.489 134.13 340.075 135.192 340.075 136.56V145.2Z" fill="#FAFAFA"/>
41 <path d="M315.483 164.911V129.81H317.103L318.669 133.806C319.173 132.438 319.857 131.304 320.721 130.404C321.585 129.468 322.737 129 324.177 129C326.265 129 327.849 129.594 328.929 130.782C330.045 131.934 330.603 133.68 330.603 136.02V139.53H326.985V135.804C326.985 134.76 326.751 133.914 326.283 133.266C325.815 132.582 325.059 132.24 324.015 132.24C323.331 132.24 322.611 132.51 321.855 133.05C321.135 133.59 320.523 134.454 320.019 135.642C319.515 136.794 319.263 138.324 319.263 140.232V164.911H315.483Z" fill="#FAFAFA"/>
42 <path d="M301.713 155.191H305.493V158.161C305.493 160.501 304.773 162.355 303.333 163.723C301.893 165.055 299.661 165.721 296.636 165.721C293.612 165.721 291.362 165.055 289.886 163.723C288.41 162.355 287.672 160.501 287.672 158.161V136.56C287.672 134.22 288.392 132.384 289.832 131.052C291.272 129.684 293.504 129 296.528 129C299.553 129 301.803 129.684 303.279 131.052C304.755 132.384 305.493 134.22 305.493 136.56V148.441H291.452V158.161C291.452 159.493 291.866 160.555 292.694 161.347C293.558 162.103 294.872 162.481 296.636 162.481C298.365 162.481 299.643 162.103 300.471 161.347C301.299 160.555 301.713 159.493 301.713 158.161V155.191ZM291.452 145.2H301.713V136.56C301.713 135.192 301.281 134.13 300.417 133.374C299.589 132.618 298.293 132.24 296.528 132.24C294.8 132.24 293.522 132.618 292.694 133.374C291.866 134.13 291.452 135.192 291.452 136.56V145.2Z" fill="#FAFAFA"/>
43 <path d="M260.684 129.81H264.464V158.161C264.464 159.493 264.878 160.555 265.706 161.347C266.534 162.103 267.776 162.481 269.432 162.481C271.124 162.481 272.384 162.103 273.212 161.347C274.04 160.555 274.454 159.493 274.454 158.161V129.81H278.235V158.161C278.235 160.501 277.479 162.355 275.966 163.723C274.49 165.055 272.312 165.721 269.432 165.721C267.776 165.721 266.39 165.433 265.274 164.857C264.158 164.281 263.258 163.525 262.574 162.589C261.926 163.561 261.026 164.335 259.874 164.911C258.722 165.451 257.336 165.721 255.716 165.721C252.836 165.721 250.64 165.055 249.128 163.723C247.652 162.355 246.914 160.501 246.914 158.161V129.81H250.694V158.161C250.694 159.493 251.108 160.555 251.936 161.347C252.8 162.103 254.06 162.481 255.716 162.481C257.408 162.481 258.65 162.103 259.442 161.347C260.27 160.555 260.684 159.493 260.684 158.161V129.81Z" fill="#FAFAFA"/>
44 <path d="M237.476 158.161C237.476 160.501 236.72 162.355 235.208 163.723C233.732 165.055 231.446 165.721 228.35 165.721C225.254 165.721 222.932 165.055 221.383 163.723C219.871 162.355 219.115 160.501 219.115 158.161V136.56C219.115 134.22 219.853 132.384 221.329 131.052C222.842 129.684 225.146 129 228.242 129C231.338 129 233.642 129.684 235.154 131.052C236.702 132.384 237.476 134.22 237.476 136.56V158.161ZM233.696 136.56C233.696 135.192 233.246 134.13 232.346 133.374C231.446 132.618 230.078 132.24 228.242 132.24C226.442 132.24 225.092 132.618 224.192 133.374C223.328 134.13 222.896 135.192 222.896 136.56V158.161C222.896 159.493 223.346 160.555 224.246 161.347C225.146 162.103 226.514 162.481 228.35 162.481C230.15 162.481 231.482 162.103 232.346 161.347C233.246 160.555 233.696 159.493 233.696 158.161V136.56Z" fill="#FAFAFA"/>
45 <path d="M194.56 164.911V129.81H202.714C205.594 129.81 207.736 130.494 209.14 131.862C210.58 133.194 211.3 135.03 211.3 137.37V146.551C211.3 148.891 210.598 150.745 209.194 152.113C207.79 153.445 205.63 154.111 202.714 154.111H198.34V164.911H194.56ZM207.52 137.37C207.52 136.038 207.106 134.994 206.278 134.238C205.486 133.446 204.298 133.05 202.714 133.05H198.34V150.871H202.714C204.334 150.871 205.54 150.493 206.332 149.737C207.124 148.945 207.52 147.883 207.52 146.551V137.37Z" fill="#FAFAFA"/>
46 </g>
47 <defs>
48 <filter id="filter0_d_303_614" x="0" y="0" width="1033" height="213" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
49 <feFlood flood-opacity="0" result="BackgroundImageFix"/>
50 <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
51 <feOffset dy="4"/>
52 <feGaussianBlur stdDeviation="2"/>
53 <feComposite in2="hardAlpha" operator="out"/>
54 <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
55 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/>
56 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/>
57 </filter>
58 <filter id="filter1_d_303_614" x="190.56" y="32" width="812.841" height="85.1917" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
59 <feFlood flood-opacity="0" result="BackgroundImageFix"/>
60 <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
61 <feOffset dy="4"/>
62 <feGaussianBlur stdDeviation="2"/>
63 <feComposite in2="hardAlpha" operator="out"/>
64 <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
65 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_303_614"/>
66 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_303_614" result="shape"/>
67 </filter>
68 <linearGradient id="paint0_linear_303_614" x1="48.4373" y1="83.8162" x2="111.888" y2="65.7367" gradientUnits="userSpaceOnUse">
69 <stop stop-color="#DD4652"/>
70 <stop offset="0.15" stop-color="#DD4854"/>
71 <stop offset="0.23" stop-color="#DE4F5B"/>
72 <stop offset="0.29" stop-color="#E15C67"/>
73 <stop offset="0.34" stop-color="#E46F78"/>
74 <stop offset="0.38" stop-color="#E9878F"/>
75 <stop offset="0.42" stop-color="#EEA5AB"/>
76 <stop offset="0.46" stop-color="#F5C8CC"/>
77 <stop offset="0.49" stop-color="#FCF0F1"/>
78 <stop offset="0.5" stop-color="#FEFDFD"/>
79 <stop offset="0.52" stop-color="#F7D4D8"/>
80 <stop offset="0.55" stop-color="#F1B0B7"/>
81 <stop offset="0.58" stop-color="#EB8F9A"/>
82 <stop offset="0.61" stop-color="#E67481"/>
83 <stop offset="0.64" stop-color="#E25E6D"/>
84 <stop offset="0.68" stop-color="#DF4D5E"/>
85 <stop offset="0.73" stop-color="#DD4153"/>
86 <stop offset="0.8" stop-color="#DC3A4D"/>
87 <stop offset="1" stop-color="#DC394C"/>
88 </linearGradient>
89 <linearGradient id="paint1_linear_303_614" x1="107.569" y1="12.2053" x2="11.6611" y2="39.2106" gradientUnits="userSpaceOnUse">
90 <stop stop-color="#8B3138" stop-opacity="0"/>
91 <stop offset="0.65" stop-color="#DC394C"/>
92 </linearGradient>
93 <linearGradient id="paint2_linear_303_614" x1="107.569" y1="189.634" x2="11.6864" y2="162.629" gradientUnits="userSpaceOnUse">
94 <stop stop-color="#8B3138" stop-opacity="0"/>
95 <stop offset="0.65" stop-color="#DC394C"/>
96 </linearGradient>
97 <linearGradient id="paint3_linear_303_614" x1="30.5598" y1="131.944" x2="125.396" y2="131.944" gradientUnits="userSpaceOnUse">
98 <stop stop-color="#8B3138" stop-opacity="0"/>
99 <stop offset="0.65" stop-color="#DC394C"/>
100 </linearGradient>
101 <linearGradient id="paint4_linear_303_614" x1="30.5847" y1="69.8189" x2="125.396" y2="69.8189" gradientUnits="userSpaceOnUse">
102 <stop stop-color="#8B3138" stop-opacity="0"/>
103 <stop offset="0.65" stop-color="#DC394C"/>
104 </linearGradient>
105 <linearGradient id="paint5_linear_303_614" x1="-48.1491" y1="140.528" x2="123.546" y2="139.131" gradientUnits="userSpaceOnUse">
106 <stop stop-color="#6A1921"/>
107 <stop offset="1" stop-color="#DC394C"/>
108 </linearGradient>
109 </defs>
110 </svg>
--- a/static/css/input.css
+++ b/static/css/input.css
@@ -0,0 +1,4 @@
1
+/* Tailwind CSS is loaded via CDN in base.html for development.
2
+ For production, use the Tailwind standalone CLI:
3
+ npx @tailwindcss/cli -i static/css/input.css -o static/css/output.css --minify
4
+*/
--- a/static/css/input.css
+++ b/static/css/input.css
@@ -0,0 +1,4 @@
 
 
 
 
--- a/static/css/input.css
+++ b/static/css/input.css
@@ -0,0 +1,4 @@
1 /* Tailwind CSS is loaded via CDN in base.html for development.
2 For production, use the Tailwind standalone CLI:
3 npx @tailwindcss/cli -i static/css/input.css -o static/css/output.css --minify
4 */
--- a/static/img/fossilrepo-logo-dark.svg
+++ b/static/img/fossilrepo-logo-dark.svg
@@ -0,0 +1,13 @@
1
+<svg width="220" height="36" viewBox="0 0 220 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <!-- Ammonite spiral icon -->
3
+ <g transform="translate(4, 2)">
4
+ <circle cx="16" cy="16" r="14" stroke="#DC394C" stroke-width="2.5" fill="none"/>
5
+ <path d="M16 2C8.268 2 2 8.268 2 16" stroke="#DC394C" stroke-width="2.5" stroke-linecap="round" fill="none"/>
6
+ <path d="M16 7C11.029 7 7 11.029 7 16c0 4.971 4.029 9 9 9" stroke="#e8677a" stroke-width="2" stroke-linecap="round" fill="none"/>
7
+ <path d="M16 12c-2.209 0-4 1.791-4 4s1.791 4 4 4" stroke="#DC394C" stroke-width="1.5" stroke-linecap="round" fill="none"/>
8
+ <circle cx="16" cy="16" r="1.5" fill="#DC394C"/>
9
+ </g>
10
+ <!-- Wordmark -->
11
+ <text x="42" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#e5e5e5" letter-spacing="-0.5">fossil</text>
12
+ <text x="102" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#DC394C" letter-spacing="-0.5">repo</text>
13
+</svg>
--- a/static/img/fossilrepo-logo-dark.svg
+++ b/static/img/fossilrepo-logo-dark.svg
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/static/img/fossilrepo-logo-dark.svg
+++ b/static/img/fossilrepo-logo-dark.svg
@@ -0,0 +1,13 @@
1 <svg width="220" height="36" viewBox="0 0 220 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2 <!-- Ammonite spiral icon -->
3 <g transform="translate(4, 2)">
4 <circle cx="16" cy="16" r="14" stroke="#DC394C" stroke-width="2.5" fill="none"/>
5 <path d="M16 2C8.268 2 2 8.268 2 16" stroke="#DC394C" stroke-width="2.5" stroke-linecap="round" fill="none"/>
6 <path d="M16 7C11.029 7 7 11.029 7 16c0 4.971 4.029 9 9 9" stroke="#e8677a" stroke-width="2" stroke-linecap="round" fill="none"/>
7 <path d="M16 12c-2.209 0-4 1.791-4 4s1.791 4 4 4" stroke="#DC394C" stroke-width="1.5" stroke-linecap="round" fill="none"/>
8 <circle cx="16" cy="16" r="1.5" fill="#DC394C"/>
9 </g>
10 <!-- Wordmark -->
11 <text x="42" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#e5e5e5" letter-spacing="-0.5">fossil</text>
12 <text x="102" y="24" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" font-size="20" font-weight="700" fill="#DC394C" letter-spacing="-0.5">repo</text>
13 </svg>
--- a/templates/403.html
+++ b/templates/403.html
@@ -0,0 +1,19 @@
1
+{% extends "base.html" %}
2
+{% block title %}Access Denied — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="flex flex-col items-center justify-center py-20">
6
+ <div class="text-6/div>
7
+ <h1 clasbrand mb-4">403</div>
8
+ <h1 class="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1>
9
+ -2">Access Denied</h1>
10
+ 6 text-center max-w-md">
11
+ You don't have perm
12
+ </p>
13
+ <div class="flex gap-3">
14
+x gap-3 jubrand px-4 py-2-[var(--brandbg-brand-hover">Go to Dashboard</a>
15
+ <button onclick="history.back()l lang="en">
16
+<head>
17
+ <<!DOCTYPE html>
18
+<h-[var(--brand)] px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition">Go Home</a>
19
+ <a href="/auth/login/" class="rounded-md bg-graygray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">G
--- a/templates/403.html
+++ b/templates/403.html
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/403.html
+++ b/templates/403.html
@@ -0,0 +1,19 @@
1 {% extends "base.html" %}
2 {% block title %}Access Denied — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="flex flex-col items-center justify-center py-20">
6 <div class="text-6/div>
7 <h1 clasbrand mb-4">403</div>
8 <h1 class="text-2xl font-bold text-gray-100 mb-2">Access Denied</h1>
9 -2">Access Denied</h1>
10 6 text-center max-w-md">
11 You don't have perm
12 </p>
13 <div class="flex gap-3">
14 x gap-3 jubrand px-4 py-2-[var(--brandbg-brand-hover">Go to Dashboard</a>
15 <button onclick="history.back()l lang="en">
16 <head>
17 <<!DOCTYPE html>
18 <h-[var(--brand)] px-5 py-2.5 text-sm font-semibold text-white hover:opacity-90 transition">Go Home</a>
19 <a href="/auth/login/" class="rounded-md bg-graygray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">G
--- a/templates/404.html
+++ b/templates/404.html
@@ -0,0 +1,15 @@
1
+{% extends "base.html" %}
2
+{% block title %}Page Not Found — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="flex flex-col items-center justify-center py-20">
6
+ <div class="text-6/div>
7
+ <h1 clasbrand mb-4">404</div>
8
+ <h1 class="text-2xl font-bold text-gray-100 mb-2">Page Not Found</h1>
9
+ 2">Page Not Found</h1>
10
+ 6 text-center max-w-md">
11
+ The page you're looking for doesn't exist or has been moved.
12
+ </p>
13
+ <div class="flex gap-3">
14
+ <a hrebrand px-4 py-2-md bg-gray-800 px-5 py-2.5 white hover:bg-brand-700 px-4 py-2-md bg-gray-800 px-5 py-2.5 text-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button>
15
+ </
--- a/templates/404.html
+++ b/templates/404.html
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/404.html
+++ b/templates/404.html
@@ -0,0 +1,15 @@
1 {% extends "base.html" %}
2 {% block title %}Page Not Found — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="flex flex-col items-center justify-center py-20">
6 <div class="text-6/div>
7 <h1 clasbrand mb-4">404</div>
8 <h1 class="text-2xl font-bold text-gray-100 mb-2">Page Not Found</h1>
9 2">Page Not Found</h1>
10 6 text-center max-w-md">
11 The page you're looking for doesn't exist or has been moved.
12 </p>
13 <div class="flex gap-3">
14 <a hrebrand px-4 py-2-md bg-gray-800 px-5 py-2.5 white hover:bg-brand-700 px-4 py-2-md bg-gray-800 px-5 py-2.5 text-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button>
15 </
--- a/templates/500.html
+++ b/templates/500.html
@@ -0,0 +1,15 @@
1
+{% extends "base.html" %}
2
+{% block title %}Server Error — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="flex flex-col items-center justify-center py-20">
6
+ <div class="text-6/div>
7
+ <h1 clasbrand mb-4">500</div>
8
+ <h1 class="text-2xl font-bold text-gray-100 mb-2">Something Went Wrong</h1>
9
+ ething Went Wrong</h1>
10
+ 6 text-center max-w-md">
11
+ An unexpected error occurred. The team has been notified.
12
+ </p>
13
+ <div class="flex gap-3">
14
+x gap-3 jubrand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button>
15
+ </
--- a/templates/500.html
+++ b/templates/500.html
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/500.html
+++ b/templates/500.html
@@ -0,0 +1,15 @@
1 {% extends "base.html" %}
2 {% block title %}Server Error — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="flex flex-col items-center justify-center py-20">
6 <div class="text-6/div>
7 <h1 clasbrand mb-4">500</div>
8 <h1 class="text-2xl font-bold text-gray-100 mb-2">Something Went Wrong</h1>
9 ething Went Wrong</h1>
10 6 text-center max-w-md">
11 An unexpected error occurred. The team has been notified.
12 </p>
13 <div class="flex gap-3">
14 x gap-3 jubrand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">Go Back</button>
15 </
--- a/templates/admin/base_site.html
+++ b/templates/admin/base_site.html
@@ -0,0 +1,44 @@
1
+{% extends "admin/base.html" %}
2
+{% load static %}
3
+
4
+{% block title %}
5
+ {% if subtitle %} {{ subtitle }} | {% endif %}
6
+ {{ title }} | {{ site_title|default:_('Django site admin') }}
7
+{% endblock %}
8
+
9
+{% block extrastyle %}
10
+ {{ block.super }}
11
+ <link rel="stylesheet" href="{% static 'admin/css/dark_theme.css' %}">
12
+{% endblock %}
13
+
14
+{% block welcome-msg %}
15
+ <strong>{% firstof user.get_short_name user.get_username %}</strong>.
16
+{% endblock %}
17
+
18
+{% block userlinks %}
19
+ <span class="bw-links">
20
+ <a target="_blank" href="/">App</a>
21
+ <a target="_blank" href="/health/">Health</a>
22
+ <a target="_blank" href="http://localhost:8025">Mailpit</a>
23
+ </span>
24
+ {% if user.has_usable_password %}
25
+ <a href="{% url 'admin:password_change' %}">PWD</a> /
26
+ {% endif %}
27
+ <form id="logout-form" method="post" action="{% url 'admin:logout' %}" style="display:inline">
28
+ {% csrf_token %}
29
+ <button type="submit">Log out</button>
30
+ </form>
31
+ {% include "admin/color_theme_toggle.html" %}
32
+{% endblock %}
33
+
34
+{% block branding %}
35
+ <h1 id="site-name">
36
+ <div class="logo">
37
+ <a role="listitem" class="item" href="/">
38
+ <img src="{% static 'admin/img/logo-dark.png' %}" alt="Fossilrepo" height="40">
39
+ </a>
40
+ </div>
41
+ </h1>
42
+{% endblock %}
43
+
44
+{% block nav-global %}{% endblock %}
--- a/templates/admin/base_site.html
+++ b/templates/admin/base_site.html
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/admin/base_site.html
+++ b/templates/admin/base_site.html
@@ -0,0 +1,44 @@
1 {% extends "admin/base.html" %}
2 {% load static %}
3
4 {% block title %}
5 {% if subtitle %} {{ subtitle }} | {% endif %}
6 {{ title }} | {{ site_title|default:_('Django site admin') }}
7 {% endblock %}
8
9 {% block extrastyle %}
10 {{ block.super }}
11 <link rel="stylesheet" href="{% static 'admin/css/dark_theme.css' %}">
12 {% endblock %}
13
14 {% block welcome-msg %}
15 <strong>{% firstof user.get_short_name user.get_username %}</strong>.
16 {% endblock %}
17
18 {% block userlinks %}
19 <span class="bw-links">
20 <a target="_blank" href="/">App</a>
21 <a target="_blank" href="/health/">Health</a>
22 <a target="_blank" href="http://localhost:8025">Mailpit</a>
23 </span>
24 {% if user.has_usable_password %}
25 <a href="{% url 'admin:password_change' %}">PWD</a> /
26 {% endif %}
27 <form id="logout-form" method="post" action="{% url 'admin:logout' %}" style="display:inline">
28 {% csrf_token %}
29 <button type="submit">Log out</button>
30 </form>
31 {% include "admin/color_theme_toggle.html" %}
32 {% endblock %}
33
34 {% block branding %}
35 <h1 id="site-name">
36 <div class="logo">
37 <a role="listitem" class="item" href="/">
38 <img src="{% static 'admin/img/logo-dark.png' %}" alt="Fossilrepo" height="40">
39 </a>
40 </div>
41 </h1>
42 {% endblock %}
43
44 {% block nav-global %}{% endblock %}
--- a/templates/auth1/login.html
+++ b/templates/auth1/login.html
@@ -0,0 +1,36 @@
1
+{% extends "base.html" %}
2
+{% load static %}
3
+{% block title %}Sign In — Fossilrecontent %}
4
+<div class="flex min-h-[80vh] items-center justify-center">
5
+ <div class="w-full max-w-sm space-y-8">
6
+ <div class="flex flex-col items-center">
7
+ <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6">
8
+ <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2>
9
+ <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p>
10
+ </div>
11
+
12
+ {% if form.errors %}
13
+ <div class="rounded-md bg-red-900/50 border border-red-700 p-4">
14
+ <p class="text-sm text-red-300">Invalid username or password.</p>
15
+ </div>
16
+ {% endif %}
17
+
18
+ /div>
19
+ {% endif %}
20
+
21
+ <form method="post" class="space-y-6">
22
+ {% csrf_token %}
23
+ <div>
24
+ <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label>
25
+ <div class="mt-1">{{ form.username }}</div>
26
+ </div>
27
+ <div>
28
+ <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label>
29
+ <div class="mt-1">{{ form.password }v>
30
+ {% endif %}
31
+ <button type="submit"
32
+ class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-gray-950 transition-colors">
33
+ Sign in
34
+ </button>
35
+ </form>
36
+ </
--- a/templates/auth1/login.html
+++ b/templates/auth1/login.html
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/auth1/login.html
+++ b/templates/auth1/login.html
@@ -0,0 +1,36 @@
1 {% extends "base.html" %}
2 {% load static %}
3 {% block title %}Sign In — Fossilrecontent %}
4 <div class="flex min-h-[80vh] items-center justify-center">
5 <div class="w-full max-w-sm space-y-8">
6 <div class="flex flex-col items-center">
7 <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-12 w-auto mb-6">
8 <h2 class="text-center text-3xl font-bold tracking-tight text-gray-100">Sign in</h2>
9 <p class="mt-2 text-center text-sm text-gray-400">Fossilrepo Django + HTMX</p>
10 </div>
11
12 {% if form.errors %}
13 <div class="rounded-md bg-red-900/50 border border-red-700 p-4">
14 <p class="text-sm text-red-300">Invalid username or password.</p>
15 </div>
16 {% endif %}
17
18 /div>
19 {% endif %}
20
21 <form method="post" class="space-y-6">
22 {% csrf_token %}
23 <div>
24 <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label>
25 <div class="mt-1">{{ form.username }}</div>
26 </div>
27 <div>
28 <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label>
29 <div class="mt-1">{{ form.password }v>
30 {% endif %}
31 <button type="submit"
32 class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-gray-950 transition-colors">
33 Sign in
34 </button>
35 </form>
36 </
--- a/templates/base.html
+++ b/templates/base.html
@@ -0,0 +1,6 @@
1
+<!DOCTYPE html>
2
+<html lang="en" class="h-full dark">
3
+<head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <metpy-6{% endif %}
--- a/templates/base.html
+++ b/templates/base.html
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/templates/base.html
+++ b/templates/base.html
@@ -0,0 +1,6 @@
1 <!DOCTYPE html>
2 <html lang="en" class="h-full dark">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <metpy-6{% endif %}
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -0,0 +1,99 @@
1
+{% extends "base.html" %}
2
+{% load static %}
3
+{% block title %}Dashboard — Fossilrepo{% endblock %}
4
+
5
+{% block extra_head %}
6
+<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
7
+{% endblock %}
8
+
9
+{% block content %}
10
+<div class="mb-6">
11
+ <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1>
12
+ <p class="mt-1 text-sm text-gray-400">Welcome back, {{ user.get_full_name|default:user.username }}</p>
13
+</div>
14
+
15
+<!-- Stats cards -->
16
+<div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
17
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4-gray-600 transition-colors">
18
+ <projects }}</div>
19
+ <div class="text-xs text-gray-500 mt-1">Projects</div>
20
+ </div>
21
+ <div class="rounded-lg bg-gray-800-gray-600 transition-colors">
22
+ <div class="text-2xl font-bold text-gray-100">{{ total_checkins|default:"0" }}</div>
23
+ <div class="text-xs text-gray-500 mt-1">Total Checkins</div>
24
+ </div>
25
+ <div cl-gray-600 transition-colors">
26
+ <tickets|default:"0" }}</div>
27
+ <div class="text-xs text-gray-500 mt-1">Tickets</div>
28
+ </div>
29
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4-gray-600 transition-colors">
30
+ <wiki|default:"0" }}</div>
31
+ <div class="text-xs text-gray-500 mt-1">ext-xs text-gray-500 mt-1">Wiki Pages</div>
32
+ </div>
33
+</div>
34
+
35
+<!-- Activity heatmap (all projects, last year) -->
36
+{% if heatmap_json %}
37
+<div class="">
38
+ <h3 class="text-smdiv class="grid grid-cols- <!-- System-wide activity chart -->
39
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4mt-1">Total Checkins</div>
40
+ </div>
41
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 shadow-sm hover:border-gray-600 transition-colors">
42
+ <div class="text-2xl font-bold text-gray-100">{{ total_tickets|default:"0" }}</div>
43
+ <div class="text-xs text-gray-500 mt-1">Tickets</div>
44
+ </div>
45
+ <div class="rounded-lg bg-gray-800 border b">
46
+ ckets|default:"0" %}
47
+{% load static %}
48
+{% block title %}Dashboard — Fossilrepo{% endblock %}
49
+
50
+{% block extra_head %}
51
+<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
52
+{% endblock %}
53
+
54
+{% block content %}
55
+<div class="mb-6">
56
+ <h1 class="text-2x{% extends "base.html" %}
57
+{% load static %}
58
+{% block title %}Dashboard — Fossilrepo{% endblock %}
59
+
60
+{% block extra_head %}
61
+<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
62
+{% endblock %}
63
+
64
+{% block content %}
65
+<div class="mb-6">
66
+ <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1>
67
+ <p class="mt-1 text-sm text-gray-400">Welcome back, {{ user.get_full_name|default:user.username }}</p>
68
+</div>
69
+
70
+<!-- Stats cards -->
71
+<div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
72
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 shadow-sm hover:border-graKnowledge Base%}
73
+ {% if perms.orga href="{% url 'admin:index' %}" class="block rounded-lg bg-gray-800 border border-gray-700 p-4 shadow-sm hover:border-brand hover:shadow-md transition-all">
74
+ <h3 class="text-sm font-semibold text-gray-100">Admin</h3>
75
+ <p class="mt-1 text-xs text-gray-500">Users, groups, permissions</p>
76
+ </a>
77
+ {% endif %}
78
+ </div>
79
+</div>
80
+
81
+{% if system_activity_json and system_activity_json != "[]" %}
82
+<script>
83
+ new Chart(document.getElementById('systemChart').getContext('2d'), {
84
+ type: 'bar',
85
+ data: {
86
+ labels: {{ system_activity_json|safe }}.map((_, i) => ''),
87
+ datasets: [{
88
+ data: {{ system_activity_json|safe }},
89
+ backgroundColor: '#DC394C',
90
+ borderRadius: 2,
91
+ barPercentage: 0.8,
92
+ categoryPercentage: 0.9,
93
+ }]
94
+ },
95
+ options: {
96
+ responsive: true,
97
+ maintainAspectRatio: false,
98
+ plugins: { legend: { display: false }, tooltip: {
99
+ callbacks: { title: (items) => { const w = 25 - items[0].dataIndex; return w === 0 ? 'This week' : w + ' week' + (w > 1 ? 's' : '') + {% endblock %}
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/dashboard.html
+++ b/templates/dashboard.html
@@ -0,0 +1,99 @@
1 {% extends "base.html" %}
2 {% load static %}
3 {% block title %}Dashboard — Fossilrepo{% endblock %}
4
5 {% block extra_head %}
6 <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
7 {% endblock %}
8
9 {% block content %}
10 <div class="mb-6">
11 <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1>
12 <p class="mt-1 text-sm text-gray-400">Welcome back, {{ user.get_full_name|default:user.username }}</p>
13 </div>
14
15 <!-- Stats cards -->
16 <div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
17 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4-gray-600 transition-colors">
18 <projects }}</div>
19 <div class="text-xs text-gray-500 mt-1">Projects</div>
20 </div>
21 <div class="rounded-lg bg-gray-800-gray-600 transition-colors">
22 <div class="text-2xl font-bold text-gray-100">{{ total_checkins|default:"0" }}</div>
23 <div class="text-xs text-gray-500 mt-1">Total Checkins</div>
24 </div>
25 <div cl-gray-600 transition-colors">
26 <tickets|default:"0" }}</div>
27 <div class="text-xs text-gray-500 mt-1">Tickets</div>
28 </div>
29 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4-gray-600 transition-colors">
30 <wiki|default:"0" }}</div>
31 <div class="text-xs text-gray-500 mt-1">ext-xs text-gray-500 mt-1">Wiki Pages</div>
32 </div>
33 </div>
34
35 <!-- Activity heatmap (all projects, last year) -->
36 {% if heatmap_json %}
37 <div class="">
38 <h3 class="text-smdiv class="grid grid-cols- <!-- System-wide activity chart -->
39 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4mt-1">Total Checkins</div>
40 </div>
41 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 shadow-sm hover:border-gray-600 transition-colors">
42 <div class="text-2xl font-bold text-gray-100">{{ total_tickets|default:"0" }}</div>
43 <div class="text-xs text-gray-500 mt-1">Tickets</div>
44 </div>
45 <div class="rounded-lg bg-gray-800 border b">
46 ckets|default:"0" %}
47 {% load static %}
48 {% block title %}Dashboard — Fossilrepo{% endblock %}
49
50 {% block extra_head %}
51 <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
52 {% endblock %}
53
54 {% block content %}
55 <div class="mb-6">
56 <h1 class="text-2x{% extends "base.html" %}
57 {% load static %}
58 {% block title %}Dashboard — Fossilrepo{% endblock %}
59
60 {% block extra_head %}
61 <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
62 {% endblock %}
63
64 {% block content %}
65 <div class="mb-6">
66 <h1 class="text-2xl font-bold text-gray-100">Dashboard</h1>
67 <p class="mt-1 text-sm text-gray-400">Welcome back, {{ user.get_full_name|default:user.username }}</p>
68 </div>
69
70 <!-- Stats cards -->
71 <div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
72 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 shadow-sm hover:border-graKnowledge Base%}
73 {% if perms.orga href="{% url 'admin:index' %}" class="block rounded-lg bg-gray-800 border border-gray-700 p-4 shadow-sm hover:border-brand hover:shadow-md transition-all">
74 <h3 class="text-sm font-semibold text-gray-100">Admin</h3>
75 <p class="mt-1 text-xs text-gray-500">Users, groups, permissions</p>
76 </a>
77 {% endif %}
78 </div>
79 </div>
80
81 {% if system_activity_json and system_activity_json != "[]" %}
82 <script>
83 new Chart(document.getElementById('systemChart').getContext('2d'), {
84 type: 'bar',
85 data: {
86 labels: {{ system_activity_json|safe }}.map((_, i) => ''),
87 datasets: [{
88 data: {{ system_activity_json|safe }},
89 backgroundColor: '#DC394C',
90 borderRadius: 2,
91 barPercentage: 0.8,
92 categoryPercentage: 0.9,
93 }]
94 },
95 options: {
96 responsive: true,
97 maintainAspectRatio: false,
98 plugins: { legend: { display: false }, tooltip: {
99 callbacks: { title: (items) => { const w = 25 - items[0].dataIndex; return w === 0 ? 'This week' : w + ' week' + (w > 1 ? 's' : '') + {% endblock %}
--- a/templates/fossil/_copy_hash.html
+++ b/templates/fossil/_copy_hash.html
@@ -0,0 +1,12 @@
1
+{% comment %}
2
+Usage: {% include "fossil/_copy_hash.html" with hash=some_uuid slug=project.slug %}
3
+Shows a truncated hash with copy-to-clipboard button.
4
+{% endcomment %}
5
+<span class="inline-flex items-center gap-1" x-data="{ copied: false }">
6
+ <a href="{% url 'fossil:checkin_detail' slug=slug checkin_uuid=hash %}" class="font-mono text-xs text-brand-light hover:text-brand">{{ hash|truncatechars:10 }}</a>
7
+ <button @click="navigator.clipboard.writeText('{{ hash }}'); copied = true; setTimeout(() => copied = false, 1500)"
8
+ class="text-gray-600 hover:text-brand-light" title="Copy full hash">
9
+ <svg x-show="!copied" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" /></svg>
10
+ <svg x-show="copied" class="h-3 w-3 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="display:none"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>
11
+ </button>
12
+</span>
--- a/templates/fossil/_copy_hash.html
+++ b/templates/fossil/_copy_hash.html
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/_copy_hash.html
+++ b/templates/fossil/_copy_hash.html
@@ -0,0 +1,12 @@
1 {% comment %}
2 Usage: {% include "fossil/_copy_hash.html" with hash=some_uuid slug=project.slug %}
3 Shows a truncated hash with copy-to-clipboard button.
4 {% endcomment %}
5 <span class="inline-flex items-center gap-1" x-data="{ copied: false }">
6 <a href="{% url 'fossil:checkin_detail' slug=slug checkin_uuid=hash %}" class="font-mono text-xs text-brand-light hover:text-brand">{{ hash|truncatechars:10 }}</a>
7 <button @click="navigator.clipboard.writeText('{{ hash }}'); copied = true; setTimeout(() => copied = false, 1500)"
8 class="text-gray-600 hover:text-brand-light" title="Copy full hash">
9 <svg x-show="!copied" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" /></svg>
10 <svg x-show="copied" class="h-3 w-3 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" style="display:none"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>
11 </button>
12 </span>
--- a/templates/fossil/_keyboard_help.html
+++ b/templates/fossil/_keyboard_help.html
@@ -0,0 +1,37 @@
1
+<!-- Keyboard shortcuts help modal -->
2
+<div x-data="{ show: false }" @keydown.shift.?.window="if (document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') show = !show" @keydown.escape.window="show = false">
3
+ <div x-show="show" x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click="show = false">
4
+ <div class="rounded-lg bg-gray-800 border border-gray-700 shadow-xl p-6 max-w-md w-full" @click.stop>
5
+ <h3 class="text-lg font-semibold text-gray-100 mb-4">Keyboard Shortcuts</h3>
6
+ <div class="space-y-2 text-sm">
7
+ <div class="flex items-center justify-between">
8
+ <span class="text-gray-400">Open search</span>
9
+ <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">/</kbd>
10
+ </div>
11
+ <div class="flex items-center justify-between">
12
+ <span class="text-gray-400">Next entry (timeline)</span>
13
+ <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">j</kbd>
14
+ </div>
15
+ <div class="flex items-center justify-between">
16
+ <span class="text-gray-400">Previous entry (timeline)</span>
17
+ <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">k</kbd>
18
+ </div>
19
+ <div class="flex items-center justify-between">
20
+ <span class="text-gray-400">Open focused entry</span>
21
+ <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">Enter</kbd>
22
+ </div>
23
+ <div class="flex items-center justify-between">
24
+ <span class="text-gray-400">Show this help</span>
25
+ <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">?</kbd>
26
+ </div>
27
+ <div class="flex items-center justify-between">
28
+ <span class="text-gray-400">Toggle theme</span>
29
+ <span class="text-gray-500 text-xs">Top nav button</span>
30
+ </div>
31
+ </div>
32
+ <div class="mt-4 text-right">
33
+ <button @click="show = false" class="text-sm text-gray-500 hover:text-white">Close</button>
34
+ </div>
35
+ </div>
36
+ </div>
37
+</div>
--- a/templates/fossil/_keyboard_help.html
+++ b/templates/fossil/_keyboard_help.html
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/_keyboard_help.html
+++ b/templates/fossil/_keyboard_help.html
@@ -0,0 +1,37 @@
1 <!-- Keyboard shortcuts help modal -->
2 <div x-data="{ show: false }" @keydown.shift.?.window="if (document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') show = !show" @keydown.escape.window="show = false">
3 <div x-show="show" x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click="show = false">
4 <div class="rounded-lg bg-gray-800 border border-gray-700 shadow-xl p-6 max-w-md w-full" @click.stop>
5 <h3 class="text-lg font-semibold text-gray-100 mb-4">Keyboard Shortcuts</h3>
6 <div class="space-y-2 text-sm">
7 <div class="flex items-center justify-between">
8 <span class="text-gray-400">Open search</span>
9 <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">/</kbd>
10 </div>
11 <div class="flex items-center justify-between">
12 <span class="text-gray-400">Next entry (timeline)</span>
13 <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">j</kbd>
14 </div>
15 <div class="flex items-center justify-between">
16 <span class="text-gray-400">Previous entry (timeline)</span>
17 <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">k</kbd>
18 </div>
19 <div class="flex items-center justify-between">
20 <span class="text-gray-400">Open focused entry</span>
21 <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">Enter</kbd>
22 </div>
23 <div class="flex items-center justify-between">
24 <span class="text-gray-400">Show this help</span>
25 <kbd class="px-2 py-0.5 rounded bg-gray-700 text-gray-300 text-xs font-mono">?</kbd>
26 </div>
27 <div class="flex items-center justify-between">
28 <span class="text-gray-400">Toggle theme</span>
29 <span class="text-gray-500 text-xs">Top nav button</span>
30 </div>
31 </div>
32 <div class="mt-4 text-right">
33 <button @click="show = false" class="text-sm text-gray-500 hover:text-white">Close</button>
34 </div>
35 </div>
36 </div>
37 </div>
--- a/templates/fossil/_project_nav.html
+++ b/templates/fossil/_project_nav.html
@@ -0,0 +1,17 @@
1
+<nav class="flex space-x-1 border-b border-gr"ria-label="Project se<nav aria-label="Project se4 ver:text-gr{% endif %}">
2
+ Code
3
+ </a>
4
+ <a href="{% url 'fossil:
5
+ <a hrefoverview% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
6
+ Wiki
7
+ </a>
8
+ <a href="{% url 'fossil:forum' slug=projOverview"Project sections" class="flex spa<navef="{% url 'fossil:branches' slug=project.slug %}"
9
+ class="px-3 py-2.5 sm:px-4 sm:py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'branches' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gr{% endif %}">
10
+ on-colors{% endif %}">
11
+ Branches
12
+ </a>
13
+ <a href="{% url 'fossil:timeline' slug=project.slug %}"
14
+ class="px-3 py-2.5 sm:px-4 sm:py-2 text-smSync
15
+ </a>
16
+ {% endif %}
17
+</nav>
--- a/templates/fossil/_project_nav.html
+++ b/templates/fossil/_project_nav.html
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/_project_nav.html
+++ b/templates/fossil/_project_nav.html
@@ -0,0 +1,17 @@
1 <nav class="flex space-x-1 border-b border-gr"ria-label="Project se<nav aria-label="Project se4 ver:text-gr{% endif %}">
2 Code
3 </a>
4 <a href="{% url 'fossil:
5 <a hrefoverview% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
6 Wiki
7 </a>
8 <a href="{% url 'fossil:forum' slug=projOverview"Project sections" class="flex spa<navef="{% url 'fossil:branches' slug=project.slug %}"
9 class="px-3 py-2.5 sm:px-4 sm:py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'branches' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gr{% endif %}">
10 on-colors{% endif %}">
11 Branches
12 </a>
13 <a href="{% url 'fossil:timeline' slug=project.slug %}"
14 class="px-3 py-2.5 sm:px-4 sm:py-2 text-smSync
15 </a>
16 {% endif %}
17 </nav>
--- a/templates/fossil/branch_list.html
+++ b/templates/fossil/branch_list.html
@@ -0,0 +1,14 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Branches — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+ble">
8
+ <table class="min-w-full divide-y divide-gray-700">
9
+ thead class="bg-gray-900/80">
10
+ <tr>
11
+ <th class="px-4 py-3 text-left textext-gray-400">Branch</th>
12
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-400">Last Checkin</th>
13
+ <th class="px-4 py-3 text-left text uppercase tracking-wider text-gray-400">By</th>
14
+ <th class="px-4No branches.</tdss="px-4endblock %}
--- a/templates/fossil/branch_list.html
+++ b/templates/fossil/branch_list.html
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/branch_list.html
+++ b/templates/fossil/branch_list.html
@@ -0,0 +1,14 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Branches — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 ble">
8 <table class="min-w-full divide-y divide-gray-700">
9 thead class="bg-gray-900/80">
10 <tr>
11 <th class="px-4 py-3 text-left textext-gray-400">Branch</th>
12 <th class="px-4 py-3 text-left text-xs font-medium uppercase text-gray-400">Last Checkin</th>
13 <th class="px-4 py-3 text-left text uppercase tracking-wider text-gray-400">By</th>
14 <th class="px-4No branches.</tdss="px-4endblock %}
--- a/templates/fossil/checkin_detail.html
+++ b/templates/fossil/checkin_detail.html
@@ -0,0 +1,95 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block extra_head %}
6
+<style>
7
+ .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
8
+ .diff-table td { padding: 0 8px; white-space: pre; vertical-align: top; line-height: 1.4rem; }
9
+ .diff-line-add { background: rgba(34, 197, 94, 0.1); }
10
+ .diff-line-add td:last-child { color: #86efac; }
11
+ .diff-line-del { background: rgba(239, 68, 68, 0.1); }
12
+ .diff-line-del td:last-child { color: #fca5a5; }
13
+ .diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
14
+ .diff-line-hunk td { color: #93c5fd; font-weight: 500; }
15
+ .diff-line-header td { color: #6b7280; font-weight: 600; }
16
+ .diff-gutter { width: 1%; user-select: none; color: #4b5563; text-align: right; padding: 0 6px; border-right: 1px solid #374151; cursor: pointer; white-space: nowrap; }
17
+ .diff-gutter:hover { color: #DC394C; }
18
+ .diff-gutter a { color: inherit; text-decoration: none; display: block; }
19
+ .line-row:target { background: rgba(220, 57, 76, 0.15) !important; }
20
+ .line-row:target .diff-gutter { color: #DC394C; font-weight: 600; }
21
+ .line-row { scposition: absolute; left: 100%; top: 50%; transform: translateY(-50%);
22
+ margin-left: 4px; z-index: 20; white-space: nowrap;
23
+ background: #1f2937; border: 1px solid #374151; border-radius: 6px;
24
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
25
+ display: flex; gap: 2px;
26
+ }
27
+ .line-popover button {
28
+ display: flex; align-items: center; gap: 4px; padding: 4px 8px;
29
+ font-size: 0.7rem; color: #d1d5db; background: transparent;
30
+ border: none; border-radius: 4px; cursor: pointer;
31
+ }
32
+ .line-popover button:hover { background: #374151; color: #fff; }
33
+ .line-popover button.copied { color: #22c55e; }
34
+</style>
35
+{% endblock %}
36
+
37
+{% block content %}
38
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
39
+{% include "fossil/_project_nav.html" %}
40
+
41
+<div class="space-y-4">
42
+ <!-- Commit header -->
43
+ <div class="rounded-lg bg-gray-800 border border-gray-700 shadow-sm">
44
+ <div class="px-6 py-5">
45
+ <p class="text-lg text-gray-100 leading-relaxed">{{ checkin.comment }}</p>
46
+ <div class="4h: 767px) { .split-diff-side:first-child { border-bottom: 1px solid #374151; } }
47
+ .split-diff-side .diff-table td:last-child { width: 100%; }
48
+ .split-line-add { background: rgba(34, 197, 94, 0.1); }
49
+ .split-line-add td:last-child { color: #86efac; }
50
+ .split-line-del { background: rgba(239, 68, 68, 0.1); }
51
+ .split-line-del td:last-child { color: #fca5a5; }
52
+ .split-line-empty { background: rgba(107, 114, 128, 0.05); }
53
+ .split-line-empty td:last-child { color: transparent; }
54
+ /* Syntax highlighting: preserve diff bg colors over hljs */
55
+ .diff-code .hljs { background: transparent !important; padding: 0 !important; }
56
+ .diff-code { display: inline; }
57
+ .line-popover {
58
+ position: absolute; left: 100%; top: 50 {% en6t-xs">
59
+ {% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %}
60
+ {% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %}
61
+ </div>
62
+ </div>
63
+
64
+ {% if fd.is_binary %}
65
+ <p class="p-4 text-sm text-gray-500">Binary file</p>
66
+ {% elif fd.diff_lines %}
67
+
68
+ <!-- Unified view -->
69
+ <div class="overflow-x-auto" x-show="mode === 'unified'">
70
+ <table class="diff-table">
71
+ <tbody>
72
+ {% for dl in fd.diff_lines %}
73
+{% if dl.new_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}"><td class="diff-gutter">{{ dl.old_num }}</td><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'" @click.outside="pop = false">{{ dl.new_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><spa2">unded text-xs>M</span>
74
+ {% endif %}
75
+ {% if fd.uuid %}
76
+ <a href="{% url 'fossil:code_file'fossil:codecounter text-xs">
77
+ overflow-x-auto">old bg-green-900is_binary %}
78
+ extends "base.html" %}
79
+{% load fossil_filters %}
80
+{% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %}
81
+
82
+{% block extra_head %}
83
+<style>
84
+ .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
85
+ .diff-table td { padding: 0 8px; white-space: pre; vertical-align: top; line-height: 1.4rem; }
86
+ .diff-line-add { background: rgba(34, 197, 94, 0.1); }
87
+ .diff-line-add td:last-child { color: #86efac; }
88
+ .diff-line-del { background: rgba(239, 68, 68, 0.1); }
89
+ .diff-line-del td:last-child { color: #fca5a5; }
90
+ .diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
91
+ .diff-line-hunk td { color: #93c5fd; font-weight: 500; }
92
+ .diff-line-header td { color: #6b7280; font-weight: 600; }
93
+ .diff-gutter { width: 1%; user-select: none; color: #4b5563; text-ali{{ dl.text }}{{ dl.text }}{{ dl.text }}</td></tr> </div>
94
+ {% if cditions }}</span></table>
95
+</div>{% endblock %}
--- a/templates/fossil/checkin_detail.html
+++ b/templates/fossil/checkin_detail.html
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/checkin_detail.html
+++ b/templates/fossil/checkin_detail.html
@@ -0,0 +1,95 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block extra_head %}
6 <style>
7 .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
8 .diff-table td { padding: 0 8px; white-space: pre; vertical-align: top; line-height: 1.4rem; }
9 .diff-line-add { background: rgba(34, 197, 94, 0.1); }
10 .diff-line-add td:last-child { color: #86efac; }
11 .diff-line-del { background: rgba(239, 68, 68, 0.1); }
12 .diff-line-del td:last-child { color: #fca5a5; }
13 .diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
14 .diff-line-hunk td { color: #93c5fd; font-weight: 500; }
15 .diff-line-header td { color: #6b7280; font-weight: 600; }
16 .diff-gutter { width: 1%; user-select: none; color: #4b5563; text-align: right; padding: 0 6px; border-right: 1px solid #374151; cursor: pointer; white-space: nowrap; }
17 .diff-gutter:hover { color: #DC394C; }
18 .diff-gutter a { color: inherit; text-decoration: none; display: block; }
19 .line-row:target { background: rgba(220, 57, 76, 0.15) !important; }
20 .line-row:target .diff-gutter { color: #DC394C; font-weight: 600; }
21 .line-row { scposition: absolute; left: 100%; top: 50%; transform: translateY(-50%);
22 margin-left: 4px; z-index: 20; white-space: nowrap;
23 background: #1f2937; border: 1px solid #374151; border-radius: 6px;
24 box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
25 display: flex; gap: 2px;
26 }
27 .line-popover button {
28 display: flex; align-items: center; gap: 4px; padding: 4px 8px;
29 font-size: 0.7rem; color: #d1d5db; background: transparent;
30 border: none; border-radius: 4px; cursor: pointer;
31 }
32 .line-popover button:hover { background: #374151; color: #fff; }
33 .line-popover button.copied { color: #22c55e; }
34 </style>
35 {% endblock %}
36
37 {% block content %}
38 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
39 {% include "fossil/_project_nav.html" %}
40
41 <div class="space-y-4">
42 <!-- Commit header -->
43 <div class="rounded-lg bg-gray-800 border border-gray-700 shadow-sm">
44 <div class="px-6 py-5">
45 <p class="text-lg text-gray-100 leading-relaxed">{{ checkin.comment }}</p>
46 <div class="4h: 767px) { .split-diff-side:first-child { border-bottom: 1px solid #374151; } }
47 .split-diff-side .diff-table td:last-child { width: 100%; }
48 .split-line-add { background: rgba(34, 197, 94, 0.1); }
49 .split-line-add td:last-child { color: #86efac; }
50 .split-line-del { background: rgba(239, 68, 68, 0.1); }
51 .split-line-del td:last-child { color: #fca5a5; }
52 .split-line-empty { background: rgba(107, 114, 128, 0.05); }
53 .split-line-empty td:last-child { color: transparent; }
54 /* Syntax highlighting: preserve diff bg colors over hljs */
55 .diff-code .hljs { background: transparent !important; padding: 0 !important; }
56 .diff-code { display: inline; }
57 .line-popover {
58 position: absolute; left: 100%; top: 50 {% en6t-xs">
59 {% if fd.additions %}<span class="text-green-400">+{{ fd.additions }}</span>{% endif %}
60 {% if fd.deletions %}<span class="text-red-400">-{{ fd.deletions }}</span>{% endif %}
61 </div>
62 </div>
63
64 {% if fd.is_binary %}
65 <p class="p-4 text-sm text-gray-500">Binary file</p>
66 {% elif fd.diff_lines %}
67
68 <!-- Unified view -->
69 <div class="overflow-x-auto" x-show="mode === 'unified'">
70 <table class="diff-table">
71 <tbody>
72 {% for dl in fd.diff_lines %}
73 {% if dl.new_num %}<tr class="diff-line-{{ dl.type }} line-row" id="diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}"><td class="diff-gutter">{{ dl.old_num }}</td><td class="diff-gutter" style="position:relative" x-data="{ pop: false, copied: false }" @click.stop="pop = !pop; window.location.hash = 'diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'" @click.outside="pop = false">{{ dl.new_num }}<div class="line-popover" x-show="pop" x-transition @click.stop><button @click="navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#diff-{{ forloop.parentloop.counter }}-R{{ dl.new_num }}'); copied = true; setTimeout(() => { copied = false; pop = false }, 1000)" :class="copied && 'copied'"><spa2">unded text-xs>M</span>
74 {% endif %}
75 {% if fd.uuid %}
76 <a href="{% url 'fossil:code_file'fossil:codecounter text-xs">
77 overflow-x-auto">old bg-green-900is_binary %}
78 extends "base.html" %}
79 {% load fossil_filters %}
80 {% block title %}{{ checkin.uuid|truncatechars:12 }} — {{ project.name }} — Fossilrepo{% endblock %}
81
82 {% block extra_head %}
83 <style>
84 .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
85 .diff-table td { padding: 0 8px; white-space: pre; vertical-align: top; line-height: 1.4rem; }
86 .diff-line-add { background: rgba(34, 197, 94, 0.1); }
87 .diff-line-add td:last-child { color: #86efac; }
88 .diff-line-del { background: rgba(239, 68, 68, 0.1); }
89 .diff-line-del td:last-child { color: #fca5a5; }
90 .diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
91 .diff-line-hunk td { color: #93c5fd; font-weight: 500; }
92 .diff-line-header td { color: #6b7280; font-weight: 600; }
93 .diff-gutter { width: 1%; user-select: none; color: #4b5563; text-ali{{ dl.text }}{{ dl.text }}{{ dl.text }}</td></tr> </div>
94 {% if cditions }}</span></table>
95 </div>{% endblock %}
--- a/templates/fossil/code_blame.html
+++ b/templates/fossil/code_blame.html
@@ -0,0 +1,12 @@
1
+{% extends "base.html" %}
2
+{% ay-100 mb-2">{{ project.name }}</h1>
3
+{% include "fossil/_project_nav.html" %}
4
+
5
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
6
+ <div class="px-4 py-3 border-b border-gray-700 flex er-gray-700 flex flex-wrap i" {% endfoass="flex items-centeay-100 mb-2">{{il:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.sluggray-700">
7
+ <div class=>
8
+ {% if forloop.lacolor: inherit;ast %}
9
+ <span class="text-gray-200">{{ crumb.name }}</span>lse %}
10
+ <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a>
11
+ {% endif %}
12
+
--- a/templates/fossil/code_blame.html
+++ b/templates/fossil/code_blame.html
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/code_blame.html
+++ b/templates/fossil/code_blame.html
@@ -0,0 +1,12 @@
1 {% extends "base.html" %}
2 {% ay-100 mb-2">{{ project.name }}</h1>
3 {% include "fossil/_project_nav.html" %}
4
5 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
6 <div class="px-4 py-3 border-b border-gray-700 flex er-gray-700 flex flex-wrap i" {% endfoass="flex items-centeay-100 mb-2">{{il:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.sluggray-700">
7 <div class=>
8 {% if forloop.lacolor: inherit;ast %}
9 <span class="text-gray-200">{{ crumb.name }}</span>lse %}
10 <a href="{% url 'fossil:code_dir' slug=project.slug dirpath=crumb.path %}" class="text-brand-light hover:text-brand">{{ crumb.name }}</a>
11 {% endif %}
12
--- a/templates/fossil/code_browser.html
+++ b/templates/fossil/code_browser.html
@@ -0,0 +1,2 @@
1
+{% extends "base.html" %}
2
+{% load
--- a/templates/fossil/code_browser.html
+++ b/templates/fossil/code_browser.html
@@ -0,0 +1,2 @@
 
 
--- a/templates/fossil/code_browser.html
+++ b/templates/fossil/code_browser.html
@@ -0,0 +1,2 @@
1 {% extends "base.html" %}
2 {% load
--- a/templates/fossil/code_file.html
+++ b/templates/fossil/code_file.html
@@ -0,0 +1,115 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block extra_head %}
5
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
6
+<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
7
+<style>
8
+ .code-table { border-collapse: collapse; width: 100%; }
9
+ .code-table td.line-num {
10
+ width: 1%; white-space: nowrap; padding: 0 16px !important;
11
+ text-align: right; user-select: none; cursor: pointer;
12
+ color: #4b5563; font-size: 0.75rem; line-height: 1.5rem;
13
+ border-right: 1px solid #374151; vertical-align: top;
14
+ }
15
+ .code-table td.line-num:hover { color: #DC394C; }
16
+ .code-table td.line-num a { color: inherit; text-decoration: none; display: block; }
17
+ .code-table td.line-code {
18
+ white-space: pre; padding: 0 16px !important;
19
+ font-size: 0.8125rem; line-height: 1.5rem; vertical-align: top;
20
+ }
21
+ .line-row:hover { background: rgba(220, 57, 76, 0.05); }
22
+ .line-row:target { background: rgba(220, 57, 76, 0.12); }
23
+ .line-row:target .line-num { color: #DC394C; font-weight: 600; }
24
+ .line-row { scroll-margin-top: 40vh; }
25
+ .line-popover {
26
+ position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
27
+ margin-left: 4px; z-index: 20; white-space: nowrap;
28
+ background: #1f2937; border: 1px solid #374151; border-radius: 6px;
29
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
30
+ display: flex; gap: 2px;
31
+ }
32
+ .line-popover button {
33
+ display: flex; align-items: center; gap: 4px; padding: 4px 8px;
34
+ font-size: 0.7rem; color: #d1d5db; background: transparent;
35
+ border: none; border-radius: 4px; cursor: pointer;
36
+ }
37
+ .line-popover button:hover { background: #374151; color: #fff; }
38
+ .line-popover button.copied { color: #22c55e; }
39
+</style>
40
+{% endblock %}
41
+
42
+{% block content %}
43
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
44
+{% include "fossil/_project_nav.html" %}
45
+
46
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
47
+ <!-- File path breadcrumb + stats -->
48
+ <div class="px-4 py-3 border-b border-gray-700 flex items-2">
49
+scroll-margin-top:"ine-row:targetitle %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
50
+
51
+{% block extra_head %}
52
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
53
+<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
54
+<style>
55
+ .code-table { border-collapse: collapse; width: 100%; }
56
+ .code-table td.line-num {
57
+ width: 1%; white-space: nowrap; padding: 0 16px !important;
58
+ text-align: right; user-select: none; cursor: pointer;
59
+ color: #4b5563; font-size: 0.75rem; line-height: 1.5rem;
60
+ border-right: 1px solid #374151; vertical-align: top;
61
+ }
62
+ .code-table td.line-num:hover { color: #DC394C; }
63
+ .code-table td.line-num a { color: inherit; text-decoration: none; display: block; }
64
+ .code-table td.line-code {
65
+ white-space: pre; padding: 0 16px !important;
66
+ font-size: 0.8125rem; line-height: 1.5rem; vertical-align: top;
67
+ }
68
+ .line-row:hover { background: rgba(220, 57, 76, 0.05); }
69
+ .line-row:target { background: rgba(220, 57, 76, 0.12); }
70
+ .line-row:target .line-num { color: #DC394C; font-weight: 600; }
71
+ .line-row { scroll-margin-top: 40vh; }
72
+ .line-popover {
73
+ position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
74
+ margin-left: 4px; z-index: 20; white-space: nowrap;
75
+ background: #1f2937; border: 1px solid #374151; border-radius: 6px;
76
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
77
+ display: flex; gap: 2px;
78
+ }
79
+ .line-popover bu{% extends "base.html" %}
80
+{% block title %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
81
+
82
+{% block extra_head %}
83
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
84
+<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
85
+<style>
86
+ .code-table { border-collapse: collapse; width: 100%; }
87
+ .code-table td.line-num {
88
+ width: 1%; white-space: nowrap; padding: 0 16px !important;
89
+ text-align: right; user-select: none; cursor: pointer;
90
+ color: #4b5563; font-size: 0.75rem; line-height: 1.5rem;
91
+ border-right: 1px solid #374151; vertical-align: top;
92
+ }
93
+ .code-table td.line-num:hover { color: #DC394C; }
94
+ .code-table td.line-num a { color: inherit; text-decoration: none; display: block; }
95
+ .code-table td.line-code {
96
+ white-space: pre; padding: 0 16px !important;
97
+ font-size: 0.8125rem; line-height: 1.5rem; vertical-align: top;
98
+ }
99
+ .line-row:hover { background: rgba(220, 57, 76, 0.05); }
100
+ .line-row:target { background: rgba(220, 57, 76, 0.12); }
101
+ .line-row:target .line-num { color: #DC394C; font-weight: 600; }
102
+ .line-row { scroll-margin-top: 40vh; }
103
+ .line-popover {
104
+ position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
105
+ margin-left: 4px; z-index: 20; white-space: nowrap;
106
+ background: #1f2937; border: 1px solid #374151; border-radius: 6px;
107
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
108
+ display: flex; gap: 2px;
109
+ }
110
+ .line-popover button {
111
+ display: flex; align-items: center; gap: 4px; padding: 4px 8px;
112
+ font-size: 0.7rem; color: #d1d5db; background: transparent;
113
+ border: none; border-radius: 4px; cursor: pointer;
114
+ }
115
+ .line-popover button:hover { background: #37
--- a/templates/fossil/code_file.html
+++ b/templates/fossil/code_file.html
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/code_file.html
+++ b/templates/fossil/code_file.html
@@ -0,0 +1,115 @@
1 {% extends "base.html" %}
2 {% block title %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block extra_head %}
5 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
6 <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
7 <style>
8 .code-table { border-collapse: collapse; width: 100%; }
9 .code-table td.line-num {
10 width: 1%; white-space: nowrap; padding: 0 16px !important;
11 text-align: right; user-select: none; cursor: pointer;
12 color: #4b5563; font-size: 0.75rem; line-height: 1.5rem;
13 border-right: 1px solid #374151; vertical-align: top;
14 }
15 .code-table td.line-num:hover { color: #DC394C; }
16 .code-table td.line-num a { color: inherit; text-decoration: none; display: block; }
17 .code-table td.line-code {
18 white-space: pre; padding: 0 16px !important;
19 font-size: 0.8125rem; line-height: 1.5rem; vertical-align: top;
20 }
21 .line-row:hover { background: rgba(220, 57, 76, 0.05); }
22 .line-row:target { background: rgba(220, 57, 76, 0.12); }
23 .line-row:target .line-num { color: #DC394C; font-weight: 600; }
24 .line-row { scroll-margin-top: 40vh; }
25 .line-popover {
26 position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
27 margin-left: 4px; z-index: 20; white-space: nowrap;
28 background: #1f2937; border: 1px solid #374151; border-radius: 6px;
29 box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
30 display: flex; gap: 2px;
31 }
32 .line-popover button {
33 display: flex; align-items: center; gap: 4px; padding: 4px 8px;
34 font-size: 0.7rem; color: #d1d5db; background: transparent;
35 border: none; border-radius: 4px; cursor: pointer;
36 }
37 .line-popover button:hover { background: #374151; color: #fff; }
38 .line-popover button.copied { color: #22c55e; }
39 </style>
40 {% endblock %}
41
42 {% block content %}
43 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
44 {% include "fossil/_project_nav.html" %}
45
46 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
47 <!-- File path breadcrumb + stats -->
48 <div class="px-4 py-3 border-b border-gray-700 flex items-2">
49 scroll-margin-top:"ine-row:targetitle %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
50
51 {% block extra_head %}
52 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
53 <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
54 <style>
55 .code-table { border-collapse: collapse; width: 100%; }
56 .code-table td.line-num {
57 width: 1%; white-space: nowrap; padding: 0 16px !important;
58 text-align: right; user-select: none; cursor: pointer;
59 color: #4b5563; font-size: 0.75rem; line-height: 1.5rem;
60 border-right: 1px solid #374151; vertical-align: top;
61 }
62 .code-table td.line-num:hover { color: #DC394C; }
63 .code-table td.line-num a { color: inherit; text-decoration: none; display: block; }
64 .code-table td.line-code {
65 white-space: pre; padding: 0 16px !important;
66 font-size: 0.8125rem; line-height: 1.5rem; vertical-align: top;
67 }
68 .line-row:hover { background: rgba(220, 57, 76, 0.05); }
69 .line-row:target { background: rgba(220, 57, 76, 0.12); }
70 .line-row:target .line-num { color: #DC394C; font-weight: 600; }
71 .line-row { scroll-margin-top: 40vh; }
72 .line-popover {
73 position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
74 margin-left: 4px; z-index: 20; white-space: nowrap;
75 background: #1f2937; border: 1px solid #374151; border-radius: 6px;
76 box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
77 display: flex; gap: 2px;
78 }
79 .line-popover bu{% extends "base.html" %}
80 {% block title %}{{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
81
82 {% block extra_head %}
83 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
84 <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
85 <style>
86 .code-table { border-collapse: collapse; width: 100%; }
87 .code-table td.line-num {
88 width: 1%; white-space: nowrap; padding: 0 16px !important;
89 text-align: right; user-select: none; cursor: pointer;
90 color: #4b5563; font-size: 0.75rem; line-height: 1.5rem;
91 border-right: 1px solid #374151; vertical-align: top;
92 }
93 .code-table td.line-num:hover { color: #DC394C; }
94 .code-table td.line-num a { color: inherit; text-decoration: none; display: block; }
95 .code-table td.line-code {
96 white-space: pre; padding: 0 16px !important;
97 font-size: 0.8125rem; line-height: 1.5rem; vertical-align: top;
98 }
99 .line-row:hover { background: rgba(220, 57, 76, 0.05); }
100 .line-row:target { background: rgba(220, 57, 76, 0.12); }
101 .line-row:target .line-num { color: #DC394C; font-weight: 600; }
102 .line-row { scroll-margin-top: 40vh; }
103 .line-popover {
104 position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
105 margin-left: 4px; z-index: 20; white-space: nowrap;
106 background: #1f2937; border: 1px solid #374151; border-radius: 6px;
107 box-shadow: 0 4px 12px rgba(0,0,0,0.4); padding: 2px;
108 display: flex; gap: 2px;
109 }
110 .line-popover button {
111 display: flex; align-items: center; gap: 4px; padding: 4px 8px;
112 font-size: 0.7rem; color: #d1d5db; background: transparent;
113 border: none; border-radius: 4px; cursor: pointer;
114 }
115 .line-popover button:hover { background: #37
--- a/templates/fossil/compare.html
+++ b/templates/fossil/compare.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Compare — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block extra_head %}
6
+<style>
7
+ .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
8
+ .diff-table td { padding: 0 8px; white-space: pre; vertical-align: top; line-height: 1.4rem; }
9
+ .diff-line-add { background: rgba(34, 197, 94, 0.1); }
10
+ .diff-line-add td:last-child { color: #86efac; }
11
+ .diff-line-del { background: rgba(239, 68, 68, 0.1); }
12
+ .diff-line-del td:last-child { color: #fca5a5; }
13
+ .diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
14
+ .diff-line-hunk td { color: #93c5fd; }
15
+ .diff-line-heat %}
16
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
17
+{% include "fossil/_project_nav.html" %}
18
+
19
+<div class="mb-6">
20
+ <h2 class="text-lg font-semibold text-gray-200 mb-3">Compare Checkins</h2>
21
+ <form method="get" class="flex items-end gap-3">
22
+ <div class="flex-1">
23
+ <label class="block </label>
24
+ <input type="text" name="from" value="{{ from_uuid }}" placeholder="Checkin hash..."
25
+ aria-label="From checkin hash"
26
+ class="w-frder-gray-700 bg-gray-800 text-gray-100 text-sm px-3 py-2 font-mono focus:border-brand focus:ring-brand">
27
+ </div>
28
+ <div class="flex-1">
29
+ <label class="block text-xs text-gray-500 mb-1">To (newer)</label>
30
+ <input type="text" name="to" value="{{ to_uuid }}" placeholder="Checkin hash..."
31
+ aria-label="To checkin hashid #374151; white-space: nowrap; }
32
+ /* Split diff view */
33
+ .split-diff { display: grid; grid-template-columns: 1fr 1fr; }
34
+ .split-diff-si.split-diffright: 1px solid #374151; }
35
+ .split-diff-side .diff-table td:last-child { width: 100%; }
36
+ .split-line-add { background: rgba(34, 197, 94, 0.1); }
37
+ .split-line-add td:last-child { color: #86efac; }
38
+ .split-line-del { background: rgba(239, 68, 68, 0.1); }
39
+ .spli
--- a/templates/fossil/compare.html
+++ b/templates/fossil/compare.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/compare.html
+++ b/templates/fossil/compare.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Compare — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block extra_head %}
6 <style>
7 .diff-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
8 .diff-table td { padding: 0 8px; white-space: pre; vertical-align: top; line-height: 1.4rem; }
9 .diff-line-add { background: rgba(34, 197, 94, 0.1); }
10 .diff-line-add td:last-child { color: #86efac; }
11 .diff-line-del { background: rgba(239, 68, 68, 0.1); }
12 .diff-line-del td:last-child { color: #fca5a5; }
13 .diff-line-hunk { background: rgba(96, 165, 250, 0.08); }
14 .diff-line-hunk td { color: #93c5fd; }
15 .diff-line-heat %}
16 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
17 {% include "fossil/_project_nav.html" %}
18
19 <div class="mb-6">
20 <h2 class="text-lg font-semibold text-gray-200 mb-3">Compare Checkins</h2>
21 <form method="get" class="flex items-end gap-3">
22 <div class="flex-1">
23 <label class="block </label>
24 <input type="text" name="from" value="{{ from_uuid }}" placeholder="Checkin hash..."
25 aria-label="From checkin hash"
26 class="w-frder-gray-700 bg-gray-800 text-gray-100 text-sm px-3 py-2 font-mono focus:border-brand focus:ring-brand">
27 </div>
28 <div class="flex-1">
29 <label class="block text-xs text-gray-500 mb-1">To (newer)</label>
30 <input type="text" name="to" value="{{ to_uuid }}" placeholder="Checkin hash..."
31 aria-label="To checkin hashid #374151; white-space: nowrap; }
32 /* Split diff view */
33 .split-diff { display: grid; grid-template-columns: 1fr 1fr; }
34 .split-diff-si.split-diffright: 1px solid #374151; }
35 .split-diff-side .diff-table td:last-child { width: 100%; }
36 .split-line-add { background: rgba(34, 197, 94, 0.1); }
37 .split-line-add td:last-child { color: #86efac; }
38 .split-line-del { background: rgba(239, 68, 68, 0.1); }
39 .spli
--- a/templates/fossil/doc_page.html
+++ b/templates/fossil/doc_page.html
@@ -0,0 +1,17 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ doc_path }} — Fossil Guide — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="max-w-4xl">
6
+ <div class="mb-4">
7
+ <a href="{% url 'fossil:docs' slug=project.slug %}" class="text-sm text-brand-light hover:text-bd">&larr; Back to FossilSCM Guide</a>
8
+ </div>
9
+
10
+ <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
11
+ <div class="px-6 py-4 border-b border-gray-700">
12
+ <h1 class="text-lg font-semibold text-gray-100 font-mono">{{ doc_path }}</h1>
13
+ </div>
14
+ <div class="px-6 py-6">
15
+ <div class="prose prose-invert prose-gray max-w-none">
16
+ {{ content_html }}
17
+
--- a/templates/fossil/doc_page.html
+++ b/templates/fossil/doc_page.html
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/doc_page.html
+++ b/templates/fossil/doc_page.html
@@ -0,0 +1,17 @@
1 {% extends "base.html" %}
2 {% block title %}{{ doc_path }} — Fossil Guide — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="max-w-4xl">
6 <div class="mb-4">
7 <a href="{% url 'fossil:docs' slug=project.slug %}" class="text-sm text-brand-light hover:text-bd">&larr; Back to FossilSCM Guide</a>
8 </div>
9
10 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
11 <div class="px-6 py-4 border-b border-gray-700">
12 <h1 class="text-lg font-semibold text-gray-100 font-mono">{{ doc_path }}</h1>
13 </div>
14 <div class="px-6 py-6">
15 <div class="prose prose-invert prose-gray max-w-none">
16 {{ content_html }}
17
--- a/templates/fossil/docs_index.html
+++ b/templates/fossil/docs_index.html
@@ -0,0 +1,62 @@
1
+{% extends "base.html"
2
+{% block title %}FossilSCM Guide — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="max-w-4xl">
6
+ <h1 class="text-2xl font-bold Guide</h1>
7
+ <p class6"text-sm text-gray-400 mb-4">Reference documentation for Fossil SCr, bundled grid grid-cols-1 gap-4 sm:grid-cols-2">
8
+
9
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-5">
10
+ <h3 class="text-sm font-semibold text-gray-200 mb-3 uppercase tracking-wider">Getting Started</h3>
11
+ <div class="space-y-2">
12
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/quickstart.wiki' %}" class="block text-sm text-bra500 flex-shrink-0"></span> Quick Start Guide</a>
13
+ <a href="{% url 'fossil:doc_page' slugblock text-sm text-braBuilding from Source</a>
14
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/concepts.wiki' %}" class="block text-sm text-braCore Concepts</a>
15
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_pathblock text-sm text-braFAQ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/chat.md' %}" class="flexgap-2 text-sm text-brand-light hover:text-brand">
16
+ <span class="w-2 h-2 rounde{% extends "base.html" %}
17
+{% block title %}FossilSCM Guide — Fossilrepo{% endblock %}
18
+
19
+{% block content %}
20
+<div class="max-w-4xl">
21
+ <h1 class="text-2xock t text-sm text-brand-light hover:text-brand">
22
+ <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Backups</a>
23
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/fileformat.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
24
+ <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> File Format</a>
25
+ </div>
26
+ </div>
27
+
28
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 sm:col-span-2">
29
+ <h3 class="text-sm font-semibold text-gray-200 mb-3 uppercase tracking-wider">Reference</h3>
30
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
31
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/changes.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
32
+ <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Changelog</a>
33
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/permutedindex.html' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
34
+ <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Command Reference</a>
35
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/th1.md' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
36
+ <span class="w-2 h-2 rounded-full bg-gray-600 flex-shrink-0"></span> TH1 Scripting
37
+ <span class="text-xs text-gray-500">(native Fossil only)</span></a>
38
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/fossil-v-git.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
39
+ <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Fossil vs Git</a>
40
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/hashpolicy.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
41
+ <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Hash Policy</a>
42
+ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/embeddeddoc.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
43
+ <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Embedded Docs</a>
44
+ </div>
45
+ </div>
46
+
47
+ </div>
48
+
49
+ <!-- FossilRepo-specific additions -->
50
+ <div class="mt-6 rounded-lg bg-gray-800/50 border border-gray-700 p-5">
51
+ <h3 class="text-sm font-semibold text-gray-200 mb-2">FossilRepo Additions</h3>
52
+ <p class="text-xs text-gray-400 mb-3">Features added by FossilRepo beyond native Fossil:</p>
53
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-400">
54
+ <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Git mirror sync (GitHub/GitLab)</span>
55
+ <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> MCP server for AI tools</span>
56
+ <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> JSON API + batch operations</span>
57
+ <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Agent workspaces + task claiming</span>
58
+ <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> CI status checks + SVG badges</span>
59
+ <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Webhooks with HMAC signing</span>
60
+ <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Org roles + project-level RBAC</span>
61
+ <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Release management with archives</span>
62
+ <span class="flex items-center gap-2"><sp
--- a/templates/fossil/docs_index.html
+++ b/templates/fossil/docs_index.html
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/docs_index.html
+++ b/templates/fossil/docs_index.html
@@ -0,0 +1,62 @@
1 {% extends "base.html"
2 {% block title %}FossilSCM Guide — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="max-w-4xl">
6 <h1 class="text-2xl font-bold Guide</h1>
7 <p class6"text-sm text-gray-400 mb-4">Reference documentation for Fossil SCr, bundled grid grid-cols-1 gap-4 sm:grid-cols-2">
8
9 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5">
10 <h3 class="text-sm font-semibold text-gray-200 mb-3 uppercase tracking-wider">Getting Started</h3>
11 <div class="space-y-2">
12 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/quickstart.wiki' %}" class="block text-sm text-bra500 flex-shrink-0"></span> Quick Start Guide</a>
13 <a href="{% url 'fossil:doc_page' slugblock text-sm text-braBuilding from Source</a>
14 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/concepts.wiki' %}" class="block text-sm text-braCore Concepts</a>
15 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_pathblock text-sm text-braFAQ <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/chat.md' %}" class="flexgap-2 text-sm text-brand-light hover:text-brand">
16 <span class="w-2 h-2 rounde{% extends "base.html" %}
17 {% block title %}FossilSCM Guide — Fossilrepo{% endblock %}
18
19 {% block content %}
20 <div class="max-w-4xl">
21 <h1 class="text-2xock t text-sm text-brand-light hover:text-brand">
22 <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Backups</a>
23 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/fileformat.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
24 <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> File Format</a>
25 </div>
26 </div>
27
28 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5 sm:col-span-2">
29 <h3 class="text-sm font-semibold text-gray-200 mb-3 uppercase tracking-wider">Reference</h3>
30 <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
31 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/changes.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
32 <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Changelog</a>
33 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/permutedindex.html' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
34 <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Command Reference</a>
35 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/th1.md' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
36 <span class="w-2 h-2 rounded-full bg-gray-600 flex-shrink-0"></span> TH1 Scripting
37 <span class="text-xs text-gray-500">(native Fossil only)</span></a>
38 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/fossil-v-git.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
39 <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Fossil vs Git</a>
40 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/hashpolicy.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
41 <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Hash Policy</a>
42 <a href="{% url 'fossil:doc_page' slug=fossil_scm_slug doc_path='www/embeddeddoc.wiki' %}" class="flex items-center gap-2 text-sm text-brand-light hover:text-brand">
43 <span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Embedded Docs</a>
44 </div>
45 </div>
46
47 </div>
48
49 <!-- FossilRepo-specific additions -->
50 <div class="mt-6 rounded-lg bg-gray-800/50 border border-gray-700 p-5">
51 <h3 class="text-sm font-semibold text-gray-200 mb-2">FossilRepo Additions</h3>
52 <p class="text-xs text-gray-400 mb-3">Features added by FossilRepo beyond native Fossil:</p>
53 <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-400">
54 <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Git mirror sync (GitHub/GitLab)</span>
55 <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> MCP server for AI tools</span>
56 <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> JSON API + batch operations</span>
57 <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Agent workspaces + task claiming</span>
58 <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> CI status checks + SVG badges</span>
59 <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Webhooks with HMAC signing</span>
60 <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Org roles + project-level RBAC</span>
61 <span class="flex items-center gap-2"><span class="w-2 h-2 rounded-full bg-green-500 flex-shrink-0"></span> Release management with archives</span>
62 <span class="flex items-center gap-2"><sp
--- a/templates/fossil/file_history.html
+++ b/templates/fossil/file_history.html
@@ -0,0 +1,37 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}History: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+{% include "fossil/_project_nav.html" %}
8
+
9
+<div class="mb-4 flex items-center justify-between">
10
+ <div>
11
+ <a href="{% url 'fossil:code_file' slug=project.slug filepath=filepath %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to file</a>
12
+ <h2 class="text-lg font-semibold text-gray-100 mt-1 font-mono">{{ filepath }}</h2>
13
+ </div>
14
+ <span class="text-sm text-gray-500">{{ history|length }} commit{{ history|length|pluralize }}</span>
15
+</div>
16
+
17
+<div class="space-y-2">
18
+ {% for commit in history %}
19
+ <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-3 flex items-start gap-3">
20
+ <div class="flex-shrink-0 mt-1">
21
+ <div class="w-2.5 h-2.5 rounded-full bg-brand"></div>
22
+ </div>
23
+ <div class="flex-1 min-w-0">
24
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}"
25
+ class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:100 }}</a>
26
+ <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
27
+ <a href="{% url 'fossil:user_activity' slug=project.slug username=comm }}</aap-3 text-xs text-gray-500">
28
+ e.html" %}
29
+{% load fossil_filters %{% extends "base.html" %}
30
+{% load fossil_filters %}
31
+{% block title %}History: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
32
+
33
+{% block content %}
34
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
35
+{% include "fossil/_project_nav.html" %}
36
+
37
+<d
--- a/templates/fossil/file_history.html
+++ b/templates/fossil/file_history.html
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/file_history.html
+++ b/templates/fossil/file_history.html
@@ -0,0 +1,37 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}History: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="mb-4 flex items-center justify-between">
10 <div>
11 <a href="{% url 'fossil:code_file' slug=project.slug filepath=filepath %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to file</a>
12 <h2 class="text-lg font-semibold text-gray-100 mt-1 font-mono">{{ filepath }}</h2>
13 </div>
14 <span class="text-sm text-gray-500">{{ history|length }} commit{{ history|length|pluralize }}</span>
15 </div>
16
17 <div class="space-y-2">
18 {% for commit in history %}
19 <div class="rounded-lg bg-gray-800 border border-gray-700 px-4 py-3 flex items-start gap-3">
20 <div class="flex-shrink-0 mt-1">
21 <div class="w-2.5 h-2.5 rounded-full bg-brand"></div>
22 </div>
23 <div class="flex-1 min-w-0">
24 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}"
25 class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:100 }}</a>
26 <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
27 <a href="{% url 'fossil:user_activity' slug=project.slug username=comm }}</aap-3 text-xs text-gray-500">
28 e.html" %}
29 {% load fossil_filters %{% extends "base.html" %}
30 {% load fossil_filters %}
31 {% block title %}History: {{ filepath }} — {{ project.name }} — Fossilrepo{% endblock %}
32
33 {% block content %}
34 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
35 {% include "fossil/_project_nav.html" %}
36
37 <d
--- a/templates/fossil/forum_list.html
+++ b/templates/fossil/forum_list.html
@@ -0,0 +1,24 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+d
8
+ </a>
9
+ {% endif %}
10
+ </div>
11
+</div>
12
+
13
+<div id="forum-content">
14
+<div class="space-y-3">
15
+ {% for post in posts %}
16
+ <div class="rounded-lg bg- </a>
17
+ {% endif %}
18
+ </dce == "django" %}
19
+ <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
20
+ cl </a>
21
+ {% endif %}s:border-brand focus:ring-b/path></svg>
22
+ ="inline-flex items-center rou py-8 text.source == "django" %}
23
+ <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
24
+ clendblock %}
--- a/templates/fossil/forum_list.html
+++ b/templates/fossil/forum_list.html
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/forum_list.html
+++ b/templates/fossil/forum_list.html
@@ -0,0 +1,24 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Forum — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 d
8 </a>
9 {% endif %}
10 </div>
11 </div>
12
13 <div id="forum-content">
14 <div class="space-y-3">
15 {% for post in posts %}
16 <div class="rounded-lg bg- </a>
17 {% endif %}
18 </dce == "django" %}
19 <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
20 cl </a>
21 {% endif %}s:border-brand focus:ring-b/path></svg>
22 ="inline-flex items-center rou py-8 text.source == "django" %}
23 <a href="{% url 'fossil:forum_thread' slug=project.slug thread_uuid=post.uuid %}"
24 clendblock %}
--- a/templates/fossil/forum_thread.html
+++ b/templates/fossil/forum_thread.html
@@ -0,0 +1,19 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+{% include "fossil/_project_nav.html" %}
8
+
9
+<div class="mb-4">
10
+ <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to forum</a>
11
+</div>
12
+
13
+<div class="space-y-3">
14
+ {% for item in posts %}
15
+ <div class="rounded-lg bg-gray-800 border border-gray-700 ds "base.html" %}
16
+{% load fossil_filters %}
17
+{% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% en" %}
18
+
19
+<div class="mb-4">{% endblock %}
--- a/templates/fossil/forum_thread.html
+++ b/templates/fossil/forum_thread.html
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/forum_thread.html
+++ b/templates/fossil/forum_thread.html
@@ -0,0 +1,19 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="mb-4">
10 <a href="{% url 'fossil:forum' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to forum</a>
11 </div>
12
13 <div class="space-y-3">
14 {% for item in posts %}
15 <div class="rounded-lg bg-gray-800 border border-gray-700 ds "base.html" %}
16 {% load fossil_filters %}
17 {% block title %}Forum Thread — {{ project.name }} — Fossilrepo{% en" %}
18
19 <div class="mb-4">{% endblock %}
--- a/templates/fossil/partials/file_tree.html
+++ b/templates/fossil/partials/file_tree.html
@@ -0,0 +1 @@
1
+<div id="file-tree"ee" class="overflow-x-auto
--- a/templates/fossil/partials/file_tree.html
+++ b/templates/fossil/partials/file_tree.html
@@ -0,0 +1 @@
 
--- a/templates/fossil/partials/file_tree.html
+++ b/templates/fossil/partials/file_tree.html
@@ -0,0 +1 @@
1 <div id="file-tree"ee" class="overflow-x-auto
--- a/templates/fossil/partials/ticket_table.html
+++ b/templates/fossil/partials/ticket_table.html
@@ -0,0 +1,16 @@
1
+<div id="ticket-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <th class="px-4 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Title</th>
6
+ <th class="px-4 py-3 text-left textext-gray-400 w-24">Status</th>
7
+ <th class="px-4 py-3 text-left textext-gray-400 w-28">Type</th>
8
+ <th class="px-4 py-3 text-left textext-gray-400 w-20">Priority</th>
9
+ <th class="px-4 py-3 text-right textext-gray-400 w-36">Created</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody clasdivide-y divide-gray-700/70 bg-gray-800">
13
+ {% for ticket in tickets %}
14
+ <t50"-right text-xs font-medium upper <td class="px-4 py-3">
15
+ <a href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}"
16
+
--- a/templates/fossil/partials/ticket_table.html
+++ b/templates/fossil/partials/ticket_table.html
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/partials/ticket_table.html
+++ b/templates/fossil/partials/ticket_table.html
@@ -0,0 +1,16 @@
1 <div id="ticket-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <th class="px-4 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Title</th>
6 <th class="px-4 py-3 text-left textext-gray-400 w-24">Status</th>
7 <th class="px-4 py-3 text-left textext-gray-400 w-28">Type</th>
8 <th class="px-4 py-3 text-left textext-gray-400 w-20">Priority</th>
9 <th class="px-4 py-3 text-right textext-gray-400 w-36">Created</th>
10 </tr>
11 </thead>
12 <tbody clasdivide-y divide-gray-700/70 bg-gray-800">
13 {% for ticket in tickets %}
14 <t50"-right text-xs font-medium upper <td class="px-4 py-3">
15 <a href="{% url 'fossil:ticket_detail' slug=project.slug ticket_uuid=ticket.uuid %}"
16
--- a/templates/fossil/partials/timeline_entries.html
+++ b/templates/fossil/partials/timeline_entries.html
@@ -0,0 +1,3 @@
1
+ackground: #DC394Ce8677a background: #DC394Ce8677a rotate(45deg);}
2
+ .tl-vline-ci {3); }
3
+ .tl-vline-other { backgr rgba(107,114,128,0.2)A@r,Tt@Kj,r@oR,C: tl-vline-ciI@16l,5:line.Q@190,5:ndforU@19T,7:{% if eX@yY,G@1Gg,4:'ci'H@zl,Q@13z,9:tl-node-wU@15j,9:tl-node-tP@15j,a:f' %}tl-node-f{% else %}tl-node-otheru@zy,I@15V,f@s0,k:style="position:absolute; top:40%; height:30%; u@uL,8:bottom:2Q@bM,I:35); border-left:2Q@bM,3:35)8@Bj,8:-right:2Q@bM,e:35); border-radius:0 0 4px 4px; z-index:1K@15U,5:ndforN@1Ol,4:TimeN@1PV,3:timO@kl,LR@1CF,1H~4x1;if item.connector75%; height:
--- a/templates/fossil/partials/timeline_entries.html
+++ b/templates/fossil/partials/timeline_entries.html
@@ -0,0 +1,3 @@
 
 
 
--- a/templates/fossil/partials/timeline_entries.html
+++ b/templates/fossil/partials/timeline_entries.html
@@ -0,0 +1,3 @@
1 ackground: #DC394Ce8677a background: #DC394Ce8677a rotate(45deg);}
2 .tl-vline-ci {3); }
3 .tl-vline-other { backgr rgba(107,114,128,0.2)A@r,Tt@Kj,r@oR,C: tl-vline-ciI@16l,5:line.Q@190,5:ndforU@19T,7:{% if eX@yY,G@1Gg,4:'ci'H@zl,Q@13z,9:tl-node-wU@15j,9:tl-node-tP@15j,a:f' %}tl-node-f{% else %}tl-node-otheru@zy,I@15V,f@s0,k:style="position:absolute; top:40%; height:30%; u@uL,8:bottom:2Q@bM,I:35); border-left:2Q@bM,3:35)8@Bj,8:-right:2Q@bM,e:35); border-radius:0 0 4px 4px; z-index:1K@15U,5:ndforN@1Ol,4:TimeN@1PV,3:timO@kl,LR@1CF,1H~4x1;if item.connector75%; height:
--- a/templates/fossil/repo_stats.html
+++ b/templates/fossil/repo_stats.html
@@ -0,0 +1,76 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Statistics — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block extra_head %}
6
+<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
7
+{% endblock %}
8
+
9
+{% block content %}
10
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
11
+{% include "fossil/_project_nav.html" %}
12
+
13
+<div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
14
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
15
+ <div class="text-2xl font-bold text-gray-100">{{ stats.checkin_count|default:"0" }}</div>
16
+ <div class="text-xs text-gray-500 mt-1">Checkins</div>
17
+ </div>
18
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
19
+ <div class="text-2xl font-bold text-gray-100">{{ stats.contributors|default:"0" }}</div>
20
+ <div class="text-xs text-gray-500 mt-1">Contributors</div>
21
+ </div>
22
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
23
+ <div class="text-2xl font-bold text-gray-100">{{ stats.total_artifacts|default:"0" }}</div>
24
+ <div class="text-xs text-gray-500 mt-1">Artifacts</div>
25
+ </div>
26
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
27
+ <div class="text-2xl font-bold text-gray-100">{{ stats.db_size_mb|default:"0" }} MB</div>
28
+ <div class="text-xs text-gray-500 mt-1">Repository Size</div>
29
+ </div>
30
+</div>
31
+
32
+{% if activity_json %}
33
+<div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6">
34
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Commit Activity (52 weeks)</h3>
35
+ <div style="height: 160px;">
36
+ <canvas id="statsChart"></canvas>
37
+ </div>
38
+</div>
39
+{% endif %}
40
+
41
+<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
42
+ <!-- Event breakdown -->
43
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
44
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Event Breakdown</h3>
45
+ <div class="space-y-2">
46
+ <div class="flex items-center justify-between">
47
+ <span class="text-sm text-gray-400">Checkins</span>
48
+ <span class="text-sm font-medium text-gray-200">{{ stats.checkin_count|def class="text-xs t>
49
+ <span class="text-sm text-gray-400">Wiki edits</span>
50
+ <span class="text-sm font-medium text-gray-200">{{ stats.wiki_events|default:"0" }}</span>
51
+ </div>
52
+ <div class="flex items-center justify-between">
53
+ <span class="text-sm text-gray-400">Ticket changes</span>
54
+ <span class="text-sm font-medium text-gray-200">{{ stats.ticket_events|default:"0" }}</span>
55
+ </div>
56
+ <div class="flex items-center justify-between">
57
+ <span class="text-sm text-gray-400">Forum posts</span>
58
+ <span class="text-sm font-medium text-gray-200">{{ stats.forum_events|default:"0" }}</span>
59
+ </div>
60
+ {% if stats.first_checkin %}
61
+ <div class="flex items-center justify-between pt-2 border-t border-gray-700">
62
+ <span class="text-sm text-gray-500">First checkin</span>
63
+ <span class="text-sm text-gray-400">{{ stats.first_checkin|date:"Y-m-d" }}</span>
64
+ </div>
65
+ {% endif %}
66
+ {% if stats.last_checkin %}
67
+ <div class="flex items-center justify-between">
68
+ <span class="text-sm text-gray-500">Last checkin</span>
69
+ <span class="text-sm text-gray-400">{{ stats.last_checkin|date:"Y-m-d" }}</span>
70
+ </div>
71
+ {% endif %}
72
+ </div>
73
+ </div>
74
+
75
+ <!-- Top contributors -->
76
+ <div class="rounded
--- a/templates/fossil/repo_stats.html
+++ b/templates/fossil/repo_stats.html
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/repo_stats.html
+++ b/templates/fossil/repo_stats.html
@@ -0,0 +1,76 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Statistics — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block extra_head %}
6 <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
7 {% endblock %}
8
9 {% block content %}
10 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
11 {% include "fossil/_project_nav.html" %}
12
13 <div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
14 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
15 <div class="text-2xl font-bold text-gray-100">{{ stats.checkin_count|default:"0" }}</div>
16 <div class="text-xs text-gray-500 mt-1">Checkins</div>
17 </div>
18 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
19 <div class="text-2xl font-bold text-gray-100">{{ stats.contributors|default:"0" }}</div>
20 <div class="text-xs text-gray-500 mt-1">Contributors</div>
21 </div>
22 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
23 <div class="text-2xl font-bold text-gray-100">{{ stats.total_artifacts|default:"0" }}</div>
24 <div class="text-xs text-gray-500 mt-1">Artifacts</div>
25 </div>
26 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
27 <div class="text-2xl font-bold text-gray-100">{{ stats.db_size_mb|default:"0" }} MB</div>
28 <div class="text-xs text-gray-500 mt-1">Repository Size</div>
29 </div>
30 </div>
31
32 {% if activity_json %}
33 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6">
34 <h3 class="text-sm font-medium text-gray-300 mb-3">Commit Activity (52 weeks)</h3>
35 <div style="height: 160px;">
36 <canvas id="statsChart"></canvas>
37 </div>
38 </div>
39 {% endif %}
40
41 <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
42 <!-- Event breakdown -->
43 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
44 <h3 class="text-sm font-medium text-gray-300 mb-3">Event Breakdown</h3>
45 <div class="space-y-2">
46 <div class="flex items-center justify-between">
47 <span class="text-sm text-gray-400">Checkins</span>
48 <span class="text-sm font-medium text-gray-200">{{ stats.checkin_count|def class="text-xs t>
49 <span class="text-sm text-gray-400">Wiki edits</span>
50 <span class="text-sm font-medium text-gray-200">{{ stats.wiki_events|default:"0" }}</span>
51 </div>
52 <div class="flex items-center justify-between">
53 <span class="text-sm text-gray-400">Ticket changes</span>
54 <span class="text-sm font-medium text-gray-200">{{ stats.ticket_events|default:"0" }}</span>
55 </div>
56 <div class="flex items-center justify-between">
57 <span class="text-sm text-gray-400">Forum posts</span>
58 <span class="text-sm font-medium text-gray-200">{{ stats.forum_events|default:"0" }}</span>
59 </div>
60 {% if stats.first_checkin %}
61 <div class="flex items-center justify-between pt-2 border-t border-gray-700">
62 <span class="text-sm text-gray-500">First checkin</span>
63 <span class="text-sm text-gray-400">{{ stats.first_checkin|date:"Y-m-d" }}</span>
64 </div>
65 {% endif %}
66 {% if stats.last_checkin %}
67 <div class="flex items-center justify-between">
68 <span class="text-sm text-gray-500">Last checkin</span>
69 <span class="text-sm text-gray-400">{{ stats.last_checkin|date:"Y-m-d" }}</span>
70 </div>
71 {% endif %}
72 </div>
73 </div>
74
75 <!-- Top contributors -->
76 <div class="rounded
--- a/templates/fossil/search.html
+++ b/templates/fossil/search.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Search — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+{% include "fossil/_project_nav.html" %}
8
+
9
+<form method="get" class=gap-2">
10
+ <input type="text" name="q" value="{{ query }}" placeholder="Search checkins, tickets, wiki..."
11
+ rder-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-4 py-2"
12
+ autofocus>
13
+ <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Search</button>
14
+ </div>
15
+</form>
16
+
17
+{% if results %}
18
+<div class="space-y-6">
19
+ {% if results.checkins %}
20
+ <div>
21
+ <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-2">Checkins ({{ results.checkins|length }})</h3>
22
+ <div class="rounded-lg bg-gray-800 border border-gray-700 divide-y divide-gray-700">
23
+ {% for c in results.checkins %}
24
+ <div class="px-4 py-3">
25
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=c.uuid %}" class="text-sm text-gray-200 hover:text-brand-light">{{ c.comment|truncatechars:100 }}</a>
26
+ <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
27
+ <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="hover:text-gray-300">{{ c.user|display_user }}</a>
28
+ ap-3 text-xs texheckin_detai
--- a/templates/fossil/search.html
+++ b/templates/fossil/search.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/search.html
+++ b/templates/fossil/search.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Search — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <form method="get" class=gap-2">
10 <input type="text" name="q" value="{{ query }}" placeholder="Search checkins, tickets, wiki..."
11 rder-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-4 py-2"
12 autofocus>
13 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Search</button>
14 </div>
15 </form>
16
17 {% if results %}
18 <div class="space-y-6">
19 {% if results.checkins %}
20 <div>
21 <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-2">Checkins ({{ results.checkins|length }})</h3>
22 <div class="rounded-lg bg-gray-800 border border-gray-700 divide-y divide-gray-700">
23 {% for c in results.checkins %}
24 <div class="px-4 py-3">
25 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=c.uuid %}" class="text-sm text-gray-200 hover:text-brand-light">{{ c.comment|truncatechars:100 }}</a>
26 <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
27 <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}" class="hover:text-gray-300">{{ c.user|display_user }}</a>
28 ap-3 text-xs texheckin_detai
--- a/templates/fossil/sync.html
+++ b/templates/fossil/sync.html
@@ -0,0 +1,16 @@
1
+2xl">──────── #}
2
+ {!-- Sync is configured — show5x-4 py-2 text-sm font-seflex items-center justify-betweentems-center justify-between mb-4">
3
+ <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2>
4
+ <span class="inline-flex�──────2xl">───────�5 mb-6"er justify-between">
5
+ <dt class="text-sm text-gray-400">Last synced</dt>
6
+ <dd class="text-sm text-gray-200">{% if fosgray-200">{{ fossil font-mono">{{ remote_url|def"No remote configured"Bc@ES,K@jF,C:mote_url %}
7
+R@aU,L@15G,T@1Tj,7:inline-I@BV,1h@TU,1c@VE,3P@Wu,19:</svg>
8
+ Pull from Upstream
9
+ </button>
10
+ </form>
11
+ {% else %}
12
+ <pL@1S0,1B:gray-500">No remote URL configured. This repository was created locally.</pM@tS,R@jU,5:mt-4 k@ju,1:3j@ke,1:33M@lO,1:
13
+7@_w,1: q@oj,3:
14
+ N@qC,2: J@pi,R@1HP,1g@qI,5:
15
+ 1C@ry,1:
16
+I@1HU,g@1Wj,3hZro;
--- a/templates/fossil/sync.html
+++ b/templates/fossil/sync.html
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/sync.html
+++ b/templates/fossil/sync.html
@@ -0,0 +1,16 @@
1 2xl">──────── #}
2 {!-- Sync is configured — show5x-4 py-2 text-sm font-seflex items-center justify-betweentems-center justify-between mb-4">
3 <h2 class="text-lg font-semibold text-gray-200">Upstream Sync</h2>
4 <span class="inline-flex�──────2xl">───────�5 mb-6"er justify-between">
5 <dt class="text-sm text-gray-400">Last synced</dt>
6 <dd class="text-sm text-gray-200">{% if fosgray-200">{{ fossil font-mono">{{ remote_url|def"No remote configured"Bc@ES,K@jF,C:mote_url %}
7 R@aU,L@15G,T@1Tj,7:inline-I@BV,1h@TU,1c@VE,3P@Wu,19:</svg>
8 Pull from Upstream
9 </button>
10 </form>
11 {% else %}
12 <pL@1S0,1B:gray-500">No remote URL configured. This repository was created locally.</pM@tS,R@jU,5:mt-4 k@ju,1:3j@ke,1:33M@lO,1:
13 7@_w,1: q@oj,3:
14 N@qC,2: J@pi,R@1HP,1g@qI,5:
15 1C@ry,1:
16 I@1HU,g@1Wj,3hZro;
--- a/templates/fossil/tag_list.html
+++ b/templates/fossil/tag_list.html
@@ -0,0 +1,13 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Tags — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+>
8
+ </div>
9
+</div>
10
+
11
+<div id="tag-content">
12
+<divNo tags.</tdcontent"
13
+ hxendblock %}
--- a/templates/fossil/tag_list.html
+++ b/templates/fossil/tag_list.html
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/tag_list.html
+++ b/templates/fossil/tag_list.html
@@ -0,0 +1,13 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Tags — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 >
8 </div>
9 </div>
10
11 <div id="tag-content">
12 <divNo tags.</tdcontent"
13 hxendblock %}
--- a/templates/fossil/technote_list.html
+++ b/templates/fossil/technote_list.html
@@ -0,0 +1,12 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}Technotes — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.naiv class="flex ite mb-4">Technotes</h2>
7
+x-5 py-4ml" %}
8
+{% load fossil_{% extends "base.html" %}
9
+{%a>
10
+ checkincheckin_uuid=note.uuid %}" hover:text-brandadiv>
11
+ {% empty %}
12
+500 py-8 tendblock %}
--- a/templates/fossil/technote_list.html
+++ b/templates/fossil/technote_list.html
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/technote_list.html
+++ b/templates/fossil/technote_list.html
@@ -0,0 +1,12 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}Technotes — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.naiv class="flex ite mb-4">Technotes</h2>
7 x-5 py-4ml" %}
8 {% load fossil_{% extends "base.html" %}
9 {%a>
10 checkincheckin_uuid=note.uuid %}" hover:text-brandadiv>
11 {% empty %}
12 500 py-8 tendblock %}
--- a/templates/fossil/ticket_detail.html
+++ b/templates/fossil/ticket_detail.html
@@ -0,0 +1,37 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}{{ ticket.title }} — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+{% include "fossil/_project_nav.html" %}
8
+
9
+<div class="mb-4">
10
+ <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to tickets</a>
11
+</div>
12
+
13
+<div class="overflow-hidden roer-gray-700">
14
+ <dl c">
15
+ <div class="px-6 py-5 border-b border-gray-700">
16
+ <div class="flex gap-3">
17
+ <div class="flex-4">
18
+ <s "base.html" %}
19
+{% load fossil_filters %}
20
+{% block title %}{{ ticket.title }} — {{ project{% extends " px-3 py-1 texn-300">{{ ticket.status }}</span flex-shrink-"Fixed" %}
21
+ <span class="inline-flex rounded-full bg-gray-700 px px-3 py-1 texgray-70icket.status }ay-300 flex-shrink-{% else %}
22
+lex rounded-full bg-yellow-900/50 px-3 py-1 text-xs font-semibold text-yellow-300 flex-shrink-{% endif %}
23
+ <code class="font-mono">{{ ticket.uuid|truncatechars:16 }}</code>
24
+ &middot; opened {{ ticket.created|timesince }} ago
25
+ </p>
26
+ </div>
27
+
28
+ <!-- Metadata grid -->
29
+ <div">
30
+ <div class="px-5 py-sm:grid-cols-2 lg:grid-cols-4">
31
+ <div>
32
+ 2 gap-x-6 gap-y-3 smm text-gray-500 uppercase">Type</dt>
33
+ <dd class="mt-0.5 text-sm 0.5 text-sm text-gray-200">{{ ticket.type|default:"—" }}</dd>
34
+ </div>
35
+ <div>
36
+ <dt class="text-xs font-medium text-gray-500 up
37
+ <span class="text-xs text{% endblock %}
--- a/templates/fossil/ticket_detail.html
+++ b/templates/fossil/ticket_detail.html
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_detail.html
+++ b/templates/fossil/ticket_detail.html
@@ -0,0 +1,37 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}{{ ticket.title }} — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="mb-4">
10 <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to tickets</a>
11 </div>
12
13 <div class="overflow-hidden roer-gray-700">
14 <dl c">
15 <div class="px-6 py-5 border-b border-gray-700">
16 <div class="flex gap-3">
17 <div class="flex-4">
18 <s "base.html" %}
19 {% load fossil_filters %}
20 {% block title %}{{ ticket.title }} — {{ project{% extends " px-3 py-1 texn-300">{{ ticket.status }}</span flex-shrink-"Fixed" %}
21 <span class="inline-flex rounded-full bg-gray-700 px px-3 py-1 texgray-70icket.status }ay-300 flex-shrink-{% else %}
22 lex rounded-full bg-yellow-900/50 px-3 py-1 text-xs font-semibold text-yellow-300 flex-shrink-{% endif %}
23 <code class="font-mono">{{ ticket.uuid|truncatechars:16 }}</code>
24 &middot; opened {{ ticket.created|timesince }} ago
25 </p>
26 </div>
27
28 <!-- Metadata grid -->
29 <div">
30 <div class="px-5 py-sm:grid-cols-2 lg:grid-cols-4">
31 <div>
32 2 gap-x-6 gap-y-3 smm text-gray-500 uppercase">Type</dt>
33 <dd class="mt-0.5 text-sm 0.5 text-sm text-gray-200">{{ ticket.type|default:"—" }}</dd>
34 </div>
35 <div>
36 <dt class="text-xs font-medium text-gray-500 up
37 <span class="text-xs text{% endblock %}
--- a/templates/fossil/ticket_form.html
+++ b/templates/fossil/ticket_form.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="mb-4">
9
+ <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Tickets</a>
10
+</div>
11
+
12
+<div class="mx-auto max-w-2xl">
13
+ <h2 class="text-xl font-bold text-gray-100 mb-4">{{ title }}</h2>
14
+
15
+ <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
16
+ {% csrf_token %}
17
+
18
+ <div>
19
+ <label class="block text-s..."
20
+ class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 shadow-inner focus:border-brand focus:ring-1 focus:ring-brand sm:text-sm transition-colors"></textarea>
21
+ </div>
22
+
23
+ {% if custom_fields %}
24
+ <div class="border-t border-gray-700 pt-4 mt-4">
25
+ <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3>
26
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
27
+ {% for cf in custom_fields %}
28
+
--- a/templates/fossil/ticket_form.html
+++ b/templates/fossil/ticket_form.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_form.html
+++ b/templates/fossil/ticket_form.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="mb-4">
9 <a href="{% url 'fossil:tickets' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Tickets</a>
10 </div>
11
12 <div class="mx-auto max-w-2xl">
13 <h2 class="text-xl font-bold text-gray-100 mb-4">{{ title }}</h2>
14
15 <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
16 {% csrf_token %}
17
18 <div>
19 <label class="block text-s..."
20 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 shadow-inner focus:border-brand focus:ring-1 focus:ring-brand sm:text-sm transition-colors"></textarea>
21 </div>
22
23 {% if custom_fields %}
24 <div class="border-t border-gray-700 pt-4 mt-4">
25 <h3 class="text-sm font-semibold text-gray-300 mb-3">Custom Fields</h3>
26 <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
27 {% for cf in custom_fields %}
28
--- a/templates/fossil/ticket_list.html
+++ b/templates/fossil/ticket_list.html
@@ -0,0 +1,36 @@
1
+input type="search"
2
+ name="search"
3
+ value="{{ search }}"
4
+ placeholder="Search tickets..."
5
+ t.name }} — Fossi{% extends "base.html" %}
6
+{% block title %}Tickets — {{ project.name }} — Fossilrepo{% endblock %}
7
+
8
+{% block content %}
9
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
10
+{% include "fossil/_project_nav.html" %}
11
+
12
+<div class="flex items-center justify-between nter justify-between gap-3 mb-4">
13
+ <div class="flex items-center gap-2 text-xs text-gray-500">
14
+ <s /00{% endif %}">Fixed</a>
15
+ <a href="{% url 'fossil:tickets' slug=project.slug %}?status=Closed"
16
+ class="rounded-full px-2.5 py-1 {% if status_filter == 'Closed' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Closed</a>
17
+ </div>
18
+ <div class="flex flex-wrap items-center gap-3">
19
+ <a href="{% url 'fossil:tickets_csv' slug=projts_csv' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light">Export CSV</a>
20
+ {% if perms.projects.change_project %}
21
+ <a href="{% url 'fossil:ticket_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover-gray-950 transition-colors">New Ticket</a>
22
+ {% endif %}
23
+ <span class="search-wrap">
24
+ <input type="search"
25
+ name="search"
26
+ value="{{ search }}"
27
+ placeholder="Search tickets..."
28
+ aria-label="Search tickets"
29
+ class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
30
+ hx-get="{% url 'fossil:tickets' slug=project.slug %}{% if status_filter %}?status={{ status_filter }}{% endif %}"
31
+ hx-trigger="input changed delay:300ms, search"
32
+ hx-target="#ticket-table"
33
+ hx-swap="outerHTML"
34
+ hx-push-url="true"
35
+ hx-indicator="closest .search-wrap" />
36
+ <svg class="search-spinner animate-spin h
--- a/templates/fossil/ticket_list.html
+++ b/templates/fossil/ticket_list.html
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/ticket_list.html
+++ b/templates/fossil/ticket_list.html
@@ -0,0 +1,36 @@
1 input type="search"
2 name="search"
3 value="{{ search }}"
4 placeholder="Search tickets..."
5 t.name }} — Fossi{% extends "base.html" %}
6 {% block title %}Tickets — {{ project.name }} — Fossilrepo{% endblock %}
7
8 {% block content %}
9 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
10 {% include "fossil/_project_nav.html" %}
11
12 <div class="flex items-center justify-between nter justify-between gap-3 mb-4">
13 <div class="flex items-center gap-2 text-xs text-gray-500">
14 <s /00{% endif %}">Fixed</a>
15 <a href="{% url 'fossil:tickets' slug=project.slug %}?status=Closed"
16 class="rounded-full px-2.5 py-1 {% if status_filter == 'Closed' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Closed</a>
17 </div>
18 <div class="flex flex-wrap items-center gap-3">
19 <a href="{% url 'fossil:tickets_csv' slug=projts_csv' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light">Export CSV</a>
20 {% if perms.projects.change_project %}
21 <a href="{% url 'fossil:ticket_create' slug=project.slug %}" class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover-gray-950 transition-colors">New Ticket</a>
22 {% endif %}
23 <span class="search-wrap">
24 <input type="search"
25 name="search"
26 value="{{ search }}"
27 placeholder="Search tickets..."
28 aria-label="Search tickets"
29 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
30 hx-get="{% url 'fossil:tickets' slug=project.slug %}{% if status_filter %}?status={{ status_filter }}{% endif %}"
31 hx-trigger="input changed delay:300ms, search"
32 hx-target="#ticket-table"
33 hx-swap="outerHTML"
34 hx-push-url="true"
35 hx-indicator="closest .search-wrap" />
36 <svg class="search-spinner animate-spin h
--- a/templates/fossil/timeline.html
+++ b/templates/fossil/timeline.html
@@ -0,0 +1,25 @@
1
+{% extends "base.html" %}
2
+{% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblossil/_live_reload.html" %}
3
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
4
+{% include "fossil/_project_nav.<div class="flex flex-wrap itmb-4">
5
+ <div class="flex <div cla="flex flex-wrap itmb-4">
6
+ <dlex flex-wrap items-center gap-2<span>Filter:</spanet="{"
7
+ class="rounnot event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
8
+ <a href="{% url 'fossil:timelineet="{type=ci"
9
+ class="rounded-full px-2.5ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border bCheckins</a>
10
+ <a href="{% url 'fossil:timelineet="{type=w"
11
+ class="rounded-full px-2.5w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-<a href="{% url 'fossil:timelineet="{type=t"
12
+ class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-7</div>
13
+<a href="{% url 'fossil:timeline_rss' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light flex items-center gap-1" title="RSS Feed">
14
+ p-1" title="RSS Feed">
15
+ <svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 RSS
16
+3V10.1Z"/></svg>
17
+ RSS
18
+ </a>
19
+</div>
20
+
21
+{% include "fossil/partials/timeline_entries.html" %}
22
+
23
+{% if entries|length == 50 %}
24
+<div id="load-more" class="mt-4 text-center"
25
+ hx-get="{
--- a/templates/fossil/timeline.html
+++ b/templates/fossil/timeline.html
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/timeline.html
+++ b/templates/fossil/timeline.html
@@ -0,0 +1,25 @@
1 {% extends "base.html" %}
2 {% block title %}Timeline — {{ project.name }} — Fossilrepo{% endblossil/_live_reload.html" %}
3 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
4 {% include "fossil/_project_nav.<div class="flex flex-wrap itmb-4">
5 <div class="flex <div cla="flex flex-wrap itmb-4">
6 <dlex flex-wrap items-center gap-2<span>Filter:</spanet="{"
7 class="rounnot event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
8 <a href="{% url 'fossil:timelineet="{type=ci"
9 class="rounded-full px-2.5ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border bCheckins</a>
10 <a href="{% url 'fossil:timelineet="{type=w"
11 class="rounded-full px-2.5w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-<a href="{% url 'fossil:timelineet="{type=t"
12 class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-7</div>
13 <a href="{% url 'fossil:timeline_rss' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light flex items-center gap-1" title="RSS Feed">
14 p-1" title="RSS Feed">
15 <svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 RSS
16 3V10.1Z"/></svg>
17 RSS
18 </a>
19 </div>
20
21 {% include "fossil/partials/timeline_entries.html" %}
22
23 {% if entries|length == 50 %}
24 <div id="load-more" class="mt-4 text-center"
25 hx-get="{
--- a/templates/fossil/user_activity.html
+++ b/templates/fossil/user_activity.html
@@ -0,0 +1,111 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ username }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+{% if heatmap_json and heatmap_json != "{}" %}
9
+<div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6">
10
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Contribution Activity</h3>
11
+ <div class="overflow-x-auto">
12
+ <div id="heatmap" style="display:flex; gap:2px; flex-wrap:wrap;"></div>
13
+ </div>
14
+</div>
15
+<script>
16
+ (function() {
17
+ const data = {{ heatmap_json|safe }};
18
+ const container = document.getElementById('heatmap');
19
+ const today = new Date();
20
+ for (let i = 364; i >= 0; i--) {
21
+ const d = new Date(today);
22
+ d.setDate(d.getDate() - i);
23
+ const key = d.toISOString().split('T')[0];
24
+ const count = data[key] || 0;
25
+ const el = document.createElement('div');
26
+ el.style.width = '10px';
27
+ el.style.height = '10px';
28
+ el.style.borderRadius = '2px';
29
+ el.title = key + ': ' + count + ' commit' + (count !== 1 ? 's' : '');
30
+ if (count === 0) el.style.background = '#1f2937';
31
+ else if (count <= 2) el.style.background = '#5b2130';
32
+ else if (count <= 5) el.style.background = '#8B3138';
33
+ else if (count <= 10) el.style.background = '#DC394C';
34
+ else el.style.background = '#e8677a';
35
+ container.appendChild(el);
36
+ }
37
+ })();
38
+</script>
39
+{% endif %}
40
+
41
+<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
42
+ <!-- Main content -->
43
+ <div class="lg:col-span-2">
44
+ <div class="rounded-lg bg-gray-800 border border-gray-700">
45
+ <div class="px-5 py-4 border-b border-gray-700">
46
+ <h2 class="text-xl font-bold text-gray-100">{{ username }}</h2>
47
+ <p class="mt-1 text-sm text-gray-400">Contributor activity in {{ project.name }}</p>
48
+ </div>
49
+
50
+ <!-- Recent checkins -->
51
+ <div class="divide-y divide-gray-700">
52
+ {% for commit in activity.checkins %}
53
+ <div class="px-5 py-3 flex items-start gap-3 hover:bg-gray-700/30">
54
+ <div class="flex-shrink-0 mt-1">
55
+ <div class="w-2.5 h-2.5 rounded-full bg-brand"></div>
56
+ </div>
57
+ <div class="flex-1 min-w-0">
58
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}"
59
+ class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:100 }}</a>
60
+ <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
61
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}"
62
+ class="font-mono text-brand-light hover:text-brand">{{ commit.uuid|truncatechars:10 }}</a>
63
+ <span>{{ commit.timestamp|timesince }} ago</span>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ {% empty %}
68
+ <p class="px-5 py-8 text-sm text-gray-500 text-center">No checkins by this user.</p>
69
+ {% endfor %}
70
+ </div>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Sidebar stats -->
75
+ <div>
76
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
77
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Contributions</h3>
78
+ <div class="space-y-1">
79
+ <a href="{% url 'fossil:timeline' slug=project.slug %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
80
+ <span class="flex items-center gap-2 text-sm text-gray-400">
81
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
82
+ Checkins
83
+ </span>
84
+ <span class="text-sm font-medium text-gray-200">{{ activity.checkin_count }}</span>
85
+ </a>
86
+ <a href="{% url 'fossil:tickets' slug=project.slug %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
87
+ <span class="flex items-center gap-2 text-sm text-gray-400">
88
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg>
89
+ Ticket changes
90
+ </span>
91
+ <span class="text-sm font-medium text-gray-200">{{ activity.ticket_count }}</span>
92
+ </a>
93
+ <a href="{% url 'fossil:wiki' slug=project.slug %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
94
+ <span class="flex items-center gap-2 text-sm text-gray-400">
95
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg>
96
+ Wiki edits
97
+ </span>
98
+ <span class="text-sm font-medium text-gray-200">{{ activity.wiki_count }}</span>
99
+ </a>
100
+ <a href="{% url 'fossil:forum' slug=project.slug %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
101
+ <span class="flex items-center gap-2 text-sm text-gray-400">
102
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg>
103
+ Forum posts
104
+ </span>
105
+ <span class="text-sm font-medium text-gray-200">{{ activity.forum_count }}</span>
106
+ </a>
107
+ </div>
108
+ </div>
109
+ </div>
110
+</div>
111
+{% endblock %}
--- a/templates/fossil/user_activity.html
+++ b/templates/fossil/user_activity.html
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/user_activity.html
+++ b/templates/fossil/user_activity.html
@@ -0,0 +1,111 @@
1 {% extends "base.html" %}
2 {% block title %}{{ username }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 {% if heatmap_json and heatmap_json != "{}" %}
9 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4 mb-6">
10 <h3 class="text-sm font-medium text-gray-300 mb-3">Contribution Activity</h3>
11 <div class="overflow-x-auto">
12 <div id="heatmap" style="display:flex; gap:2px; flex-wrap:wrap;"></div>
13 </div>
14 </div>
15 <script>
16 (function() {
17 const data = {{ heatmap_json|safe }};
18 const container = document.getElementById('heatmap');
19 const today = new Date();
20 for (let i = 364; i >= 0; i--) {
21 const d = new Date(today);
22 d.setDate(d.getDate() - i);
23 const key = d.toISOString().split('T')[0];
24 const count = data[key] || 0;
25 const el = document.createElement('div');
26 el.style.width = '10px';
27 el.style.height = '10px';
28 el.style.borderRadius = '2px';
29 el.title = key + ': ' + count + ' commit' + (count !== 1 ? 's' : '');
30 if (count === 0) el.style.background = '#1f2937';
31 else if (count <= 2) el.style.background = '#5b2130';
32 else if (count <= 5) el.style.background = '#8B3138';
33 else if (count <= 10) el.style.background = '#DC394C';
34 else el.style.background = '#e8677a';
35 container.appendChild(el);
36 }
37 })();
38 </script>
39 {% endif %}
40
41 <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
42 <!-- Main content -->
43 <div class="lg:col-span-2">
44 <div class="rounded-lg bg-gray-800 border border-gray-700">
45 <div class="px-5 py-4 border-b border-gray-700">
46 <h2 class="text-xl font-bold text-gray-100">{{ username }}</h2>
47 <p class="mt-1 text-sm text-gray-400">Contributor activity in {{ project.name }}</p>
48 </div>
49
50 <!-- Recent checkins -->
51 <div class="divide-y divide-gray-700">
52 {% for commit in activity.checkins %}
53 <div class="px-5 py-3 flex items-start gap-3 hover:bg-gray-700/30">
54 <div class="flex-shrink-0 mt-1">
55 <div class="w-2.5 h-2.5 rounded-full bg-brand"></div>
56 </div>
57 <div class="flex-1 min-w-0">
58 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}"
59 class="text-sm text-gray-200 hover:text-brand-light">{{ commit.comment|truncatechars:100 }}</a>
60 <div class="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
61 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=commit.uuid %}"
62 class="font-mono text-brand-light hover:text-brand">{{ commit.uuid|truncatechars:10 }}</a>
63 <span>{{ commit.timestamp|timesince }} ago</span>
64 </div>
65 </div>
66 </div>
67 {% empty %}
68 <p class="px-5 py-8 text-sm text-gray-500 text-center">No checkins by this user.</p>
69 {% endfor %}
70 </div>
71 </div>
72 </div>
73
74 <!-- Sidebar stats -->
75 <div>
76 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
77 <h3 class="text-sm font-medium text-gray-300 mb-3">Contributions</h3>
78 <div class="space-y-1">
79 <a href="{% url 'fossil:timeline' slug=project.slug %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
80 <span class="flex items-center gap-2 text-sm text-gray-400">
81 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
82 Checkins
83 </span>
84 <span class="text-sm font-medium text-gray-200">{{ activity.checkin_count }}</span>
85 </a>
86 <a href="{% url 'fossil:tickets' slug=project.slug %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
87 <span class="flex items-center gap-2 text-sm text-gray-400">
88 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /></svg>
89 Ticket changes
90 </span>
91 <span class="text-sm font-medium text-gray-200">{{ activity.ticket_count }}</span>
92 </a>
93 <a href="{% url 'fossil:wiki' slug=project.slug %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
94 <span class="flex items-center gap-2 text-sm text-gray-400">
95 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" /></svg>
96 Wiki edits
97 </span>
98 <span class="text-sm font-medium text-gray-200">{{ activity.wiki_count }}</span>
99 </a>
100 <a href="{% url 'fossil:forum' slug=project.slug %}" class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
101 <span class="flex items-center gap-2 text-sm text-gray-400">
102 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" /></svg>
103 Forum posts
104 </span>
105 <span class="text-sm font-medium text-gray-200">{{ activity.forum_count }}</span>
106 </a>
107 </div>
108 </div>
109 </div>
110 </div>
111 {% endblock %}
--- a/templates/fossil/wiki_form.html
+++ b/templates/fossil/wiki_form.html
@@ -0,0 +1,24 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block extra_head %}
5
+<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
6
+{% endblock %}
7
+
8
+{% block content %}
9
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
10
+{% include "fossil/_project_nav.html" %}
11
+
12
+<div class="mb-4">
13
+ <a href="{% url 'fossil:wiki' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Wiki</a>
14
+</div>
15
+
16
+<div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }">
17
+ <div class="flex items-center justify-between mb-4">
18
+ <h2 class="text-xl font-bold text-gray-100">{{ title }}</h2>
19
+ <div class="flex items-center gap-1 text-xs">
20
+ <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button>
21
+ <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = rHTML = DOMPurify.sanitize(marked" :class ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview>Preview</button>
22
+ </ddiv>
23
+
24
+ <form method8
--- a/templates/fossil/wiki_form.html
+++ b/templates/fossil/wiki_form.html
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/wiki_form.html
+++ b/templates/fossil/wiki_form.html
@@ -0,0 +1,24 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block extra_head %}
5 <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
6 {% endblock %}
7
8 {% block content %}
9 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
10 {% include "fossil/_project_nav.html" %}
11
12 <div class="mb-4">
13 <a href="{% url 'fossil:wiki' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Wiki</a>
14 </div>
15
16 <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }">
17 <div class="flex items-center justify-between mb-4">
18 <h2 class="text-xl font-bold text-gray-100">{{ title }}</h2>
19 <div class="flex items-center gap-1 text-xs">
20 <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button>
21 <button @click="tab = 'preview'; document.getElementById('preview-pane').innerHTML = rHTML = DOMPurify.sanitize(marked" :class ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview>Preview</button>
22 </ddiv>
23
24 <form method8
--- a/templates/fossil/wiki_list.html
+++ b/templates/fossil/wiki_list.html
@@ -0,0 +1,6 @@
1
+{% extends "base.html" %}
2
+{% blotext-gray-400 hx-swap="innerHTML{ p.name }}
3
+ged dela <h2 class="text-lg font-semibold text-gray-100">{{ home_page.name }}</h2>
4
+ </div>
5
+ <div class="px-6 py-6">
6
+ <div class="prose prose-invert prose-gray max-w-seendblock %}
--- a/templates/fossil/wiki_list.html
+++ b/templates/fossil/wiki_list.html
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/templates/fossil/wiki_list.html
+++ b/templates/fossil/wiki_list.html
@@ -0,0 +1,6 @@
1 {% extends "base.html" %}
2 {% blotext-gray-400 hx-swap="innerHTML{ p.name }}
3 ged dela <h2 class="text-lg font-semibold text-gray-100">{{ home_page.name }}</h2>
4 </div>
5 <div class="px-6 py-6">
6 <div class="prose prose-invert prose-gray max-w-seendblock %}
--- a/templates/fossil/wiki_page.html
+++ b/templates/fossil/wiki_page.html
@@ -0,0 +1,41 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+{% include "fossil/_project_nav.html" %}
8
+
9
+<div class="flex gap-6">
10
+ <!-- Main wiki content -->
11
+ <div class="flex-1 min-w-0">
12
+ <div class="overflow-hidden roed-lg bg-gray-800 shadow-sm border border-gray-700">
13
+ <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
14
+ <h2 class="text-lg font-semibold text-gray-100">{{ page.name }}</h2>
15
+ <div class="flex items-center gap-3">
16
+ <span class="text-xs text-gray-500">{{ page.last_modified|times }}</span>
17
+ {% if perms.projects.change_project %}
18
+ <a href="{% url 'fossil:wiki_edit' slug=project.slug page_name=page.name %}" class="rounded-md bg-gray-700 px-2 py-1 text-xs font-semibold text-gray-600 transition-colors">Edit</a>
19
+ {% endif %}
20
+ </div>
21
+ </div>
22
+ <div class="px-6 py-6">
23
+ <div class="prose prose-invert prose-gray max-w-none">
24
+ {{ content_html }}
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <!-- Right sidebar: wiki page nav -->
31
+ <aside class="hidden lg:block w-52 flex-shrink-0">
32
+ <div class="sticky top-6">
33
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">Wiki Pages</h3>
34
+ <nav class="space-y-0.5">
35
+ {% for p in all_pages %}
36
+ <a href="{% url 'fossil:wiki_page' slug=project.slug page_name=p.name %}"
37
+ class="block rounded-md px-3 py-1.5 text-sm {% if p.name == page.name %}bg-gray-800 text-brand-light font-medium border border-gray-700{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
38
+ {{ p.name }}
39
+ </a>
40
+ {% endfor %}
41
+
--- a/templates/fossil/wiki_page.html
+++ b/templates/fossil/wiki_page.html
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/wiki_page.html
+++ b/templates/fossil/wiki_page.html
@@ -0,0 +1,41 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}{{ page.name }} — Wiki — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="flex gap-6">
10 <!-- Main wiki content -->
11 <div class="flex-1 min-w-0">
12 <div class="overflow-hidden roed-lg bg-gray-800 shadow-sm border border-gray-700">
13 <div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
14 <h2 class="text-lg font-semibold text-gray-100">{{ page.name }}</h2>
15 <div class="flex items-center gap-3">
16 <span class="text-xs text-gray-500">{{ page.last_modified|times }}</span>
17 {% if perms.projects.change_project %}
18 <a href="{% url 'fossil:wiki_edit' slug=project.slug page_name=page.name %}" class="rounded-md bg-gray-700 px-2 py-1 text-xs font-semibold text-gray-600 transition-colors">Edit</a>
19 {% endif %}
20 </div>
21 </div>
22 <div class="px-6 py-6">
23 <div class="prose prose-invert prose-gray max-w-none">
24 {{ content_html }}
25 </div>
26 </div>
27 </div>
28 </div>
29
30 <!-- Right sidebar: wiki page nav -->
31 <aside class="hidden lg:block w-52 flex-shrink-0">
32 <div class="sticky top-6">
33 <h3 class="text-xs font-semibold uppercase tracking-wider text-gray-500 mb-3">Wiki Pages</h3>
34 <nav class="space-y-0.5">
35 {% for p in all_pages %}
36 <a href="{% url 'fossil:wiki_page' slug=project.slug page_name=p.name %}"
37 class="block rounded-md px-3 py-1.5 text-sm {% if p.name == page.name %}bg-gray-800 text-brand-light font-medium border border-gray-700{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
38 {{ p.name }}
39 </a>
40 {% endfor %}
41
--- a/templates/includes/nav.html
+++ b/templates/includes/nav.html
@@ -0,0 +1,30 @@
1
+{% load static %}
2
+<nav ia-label="Main navigation" class="bg-gray-900 border-b border-gray-700">
3
+ <div class="px-4 sm:px-6 lg:px-8">
4
+ <div class="flex h-14 items-center justify-between">
5
+ <div class="flex ite </button>
6
+ <a href="{% url 'dashboard' %}" class="flex-shrink-0">
7
+ <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto">
8
+ </a>
9
+ </div>
10
+ <div class="flex items-center gap-3">
11
+ <!-- Quick search -->
12
+ <div x-data="{ open: false }" class="relative">
13
+ <button @click="open = !open; $nextTick(() => $refs.searchInput?.focus())" class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" title="Search (/)">
14
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
15
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
16
+ </svg>
17
+ </button>
18
+ <div x-show="open" @click.outside="open = false" @keydown.escape.window="open = false" x-transition
19
+ class="absolute right-0 z-20 mt-2 w-80 rounded-lg bg-gray-800 shadow-lg ring-1 ring-gray-700 p-3">
20
+ <form method="get" action="{% if request.resolver_match.kwargs.slug %}/projects/{{ request.resolver_match.kwargs.slug }}/fossil/search/{% else %}/projects/fossil-scm/fossil/search/{% endif %}">
21
+ <input type="text" name="q" x-ref="searchInput" placeholder="Search checkins, tickets, wikclass="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
22
+ </form>
23
+ </div>
24
+ </div>
25
+ <!-- Theme toggle -->
26
+ <button x-data="{ dark: document.documentElement.classList.contains('dark') }"
27
+ @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')"
28
+ class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800"
29
+ title="Toggle theme">
30
+ <svg x-show="dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5
--- a/templates/includes/nav.html
+++ b/templates/includes/nav.html
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/includes/nav.html
+++ b/templates/includes/nav.html
@@ -0,0 +1,30 @@
1 {% load static %}
2 <nav ia-label="Main navigation" class="bg-gray-900 border-b border-gray-700">
3 <div class="px-4 sm:px-6 lg:px-8">
4 <div class="flex h-14 items-center justify-between">
5 <div class="flex ite </button>
6 <a href="{% url 'dashboard' %}" class="flex-shrink-0">
7 <img src="{% static 'img/fossilrepo-logo-dark.png' %}" alt="Fossilrepo" class="h-7 w-auto">
8 </a>
9 </div>
10 <div class="flex items-center gap-3">
11 <!-- Quick search -->
12 <div x-data="{ open: false }" class="relative">
13 <button @click="open = !open; $nextTick(() => $refs.searchInput?.focus())" class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800" title="Search (/)">
14 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
15 <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
16 </svg>
17 </button>
18 <div x-show="open" @click.outside="open = false" @keydown.escape.window="open = false" x-transition
19 class="absolute right-0 z-20 mt-2 w-80 rounded-lg bg-gray-800 shadow-lg ring-1 ring-gray-700 p-3">
20 <form method="get" action="{% if request.resolver_match.kwargs.slug %}/projects/{{ request.resolver_match.kwargs.slug }}/fossil/search/{% else %}/projects/fossil-scm/fossil/search/{% endif %}">
21 <input type="text" name="q" x-ref="searchInput" placeholder="Search checkins, tickets, wikclass="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
22 </form>
23 </div>
24 </div>
25 <!-- Theme toggle -->
26 <button x-data="{ dark: document.documentElement.classList.contains('dark') }"
27 @click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('theme', dark ? 'dark' : 'light')"
28 class="rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800"
29 title="Toggle theme">
30 <svg x-show="dark" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5
--- a/templates/includes/sidebar.html
+++ b/templates/includes/sidebar.html
@@ -0,0 +1,133 @@
1
+<aside-label="Sidebar
2
+ {% endKBendfor %}
3
+ </div>
4
+ </dewBox="0 0 24 24" stroke-w-full rounded-md px-2 py-2 mb-1 text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
5
+ title="Expand sidebar">
6
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentCo <nav class="flex-1 px-2 py-4 space-y-1">
7
+
8
+ <!-- Collapse toggle -->
9
+8.997 0 017.843 4.582M12 3a8.9= !collapsed {% if sidebarcollapsed)"
10
+ 0-5.74-1.1-7.843-centerarCollapsed', 'false')"
11
+ white0 hover:text-white <aside-labeExpand sideb25 9v.776" />
12
+ </svg>
13
+ {{ entry.group.name }}
14
+ </div> (projectsOpen = !projectsOpen)product docs — read-only) -->
15
+</svg>
16
+ <svg x-show="s-center gap-2">
17
+ h-4 w-425 9v.776" />
18
+ </svg>
19
+ {{ entry.group.name }}
20
+ style="display:none">
21
+ont-medium {% if request.path == '/explore/' %}bg-gray-800 text-w11 viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
22
+ <dashboard' %}"
23
+400 hover:text-white h<aside-label="Sidebar
24
+ {% endKBendfor %}
25
+ </div>
26
+ </dewBox="0 0 24 24" stroke-w-full rounded-md px-2 py-2 mb-1 text-gray75h3.879a1.5 1.5 075 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-label="Sidebar navigation"ass="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
27
+
28
+ <nav class="flex-1 px-2 py-3 space-yered -->
29
+ <button <nav class="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
30
+
31
+ <!-- Expand button (collapsed state only) — prominent, centered -->
32
+ <button x-show="collapsed" style="display:none"
33
+ @click="collapsed = false; localStorage.setItem('sidebarCollapsed', 'false')"
34
+ class="flex items-center justify-center w-full rounded-md px-2 py-2 mb-1 text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
35
+ title="Expand sidebar">
36
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
37
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
38
+ </svg>
39
+ </button>
40
+
41
+ <!-- Dashboard + collapse toggle (expanded state) -->
42
+ <div class="flex items-center gap-1" :class="collapsed && 'justify-center'">
43
+ <a href="{% url 'dashboard' %}"
44
+ class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
45
+ :class="collapsed ? '' : 'flex-1'"
46
+ :title="collapsed ? 'Dashboard' : ''">
47
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
48
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 0 0021.75 18V9a2.2 12.75V12A2.25 2.25 <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }">
49
+ 7 8.997 0 017.843 4.58open = !open"
50
+ 1.5 text-sm {% if projectojectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
51
+ <span{{ project.name }}</spanllapsed = false, projectsOpen = t flex-shrink-0 stroke="currentColor">
52
+ o2between w-full rounded-md px-2 py- docsOpen: false </button>
53
+
54
+ <!-- Projex-show="open3 mt-0.50A9.015 9./50 pl-2">
55
+ projects:dean>
56
+ </a>
57
+
58
+ <!-- Projects sepsed = false, r
59
+ false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
60
+ class="flex items-cent (projectsOpen = !projectsOpen)1.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />ollapsed ? 'Proje Overview
61
+ {% withfossil:code' slug=pslug %}"an>
62
+ </a>
63
+
64
+ <!-- Projects sepsed = false, r
65
+ fossil/code 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
66
+ apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
67
+ (projectsOpen = !projectsOpen)7.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />ollapsed ? 'Proje Code
68
+ fossil:timeline' slug=pslug %}"an>
69
+ </a>
70
+
71
+ <!-- Projects sepsed = false, r
72
+ fossil/timeline 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
73
+ apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
74
+ (projectsOpen = !projectsOpen)"
75
+ v6h4.5m4.5 0a9 9 ollapsed ? 'Proje Timeline
76
+ fossil:tickets' slug=pslug %}"an>
77
+ </a>
78
+
79
+ <!-- Projects sepsed = false, r
80
+ fossil/tickets 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
81
+ apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
82
+ (projectsOpen = !projectsOpen)6.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026nk-0 bg-gray-900 border-r borh17.av class="flex-1 px-2 py-3 spacev-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />ollapsed ? 'Proje Tickets
83
+ fossil:wiki' slug=pslug %}"an>
84
+ </a>
85
+
86
+ <!-- Projects sepsed = false, r
87
+ fossil/wiki 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
88
+ apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
89
+ (projectsOpen = !projectsOpen)"
90
+ class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/projects/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
91
+ ollapsed ? 'Proje Wiki
92
+ fossil:forum' slug=pslug %}"an>
93
+ </a>
94
+
95
+ <!-- Projects sepsed = false, r
96
+ fossil/forum 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
97
+ apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
98
+ (projectsOpen = !projectsOpen7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />ollapsed ? 'Proje Forum
99
+ {% endwith %-linejoin="round" d="M2.25 5 6v12a2.25 apsed" class="truncate">Projects</span>
100
+ </span>
101
+ <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
102
+ <path stroke-linecap="rouTeams class="flex-1 px-2 p-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 75h3.879a1.5 1.5 0 01Teams18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 text-gray-400 hove0zm-13.5 0a2.25 2.25 0 11-4.5 0 text-gray-400 hove0zTeams</span>
103
+ </a <!-- Knowledge Base
104
+
105
+ <nav class="flex-1 px-2 py-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
106
+ </svg>
107
+ Knowledge Bas 12l8.954-8.955a<aside-label="Sidebar
108
+ {% endKBendfor %}
109
+ </div>
110
+ </dewBox="0 0 24 24" stroke-w-full rounded-md px-2 py-2 mb-1 text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
111
+ debar
112
+ {% endKBendfor %}
113
+ </div>
114
+ </dewBox="0 0 24 24" stroke-w-full rounded-md px-2 py-2 mb-1 text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
115
+ title="Expand sidebar">
116
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-widKnowledge Base <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
117
+ </svg>
118
+ <span x-show="!collapsed" class="truncate">Projects</span>
119
+ </span>
120
+ <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
121
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
122
+ </svg>
123
+ </button>
124
+ <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2">
125
+ {% for entry in sidebar_grouped %}
126
+ <div class="mt-2">
127
+ < Guid 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 Settings class="flex-1 px-2 psettings' %}"
128
+400 hover:text-white hov<aside-label="Sidebar
129
+ and '/settings/teams/' notside-label="Sidebar<75h3.879a1.5 1.5 0 01Settings (projectsOpen = !projectsOpen)Settings</span>
130
+ </a <!-- Admin -->
131
+admin:index' %}"
132
+400 hover:text-w<aside-label="SidebarAdmin11.42 15.17l-5.385 3.171a.75.75 0 01-1.089-.791l1.414-5.816-4.534-3.89a.75.75 0 01.422-1.317l5.953-.493 2.354-5.476a.75.75 0 011.39 0l2.354 5.476 5.953.493a.75.75 0 01.422 1.317l-4.534 3.89 1.414 5.816a.75.75 0 01-1Admin</span>
133
+ </a</aside>
--- a/templates/includes/sidebar.html
+++ b/templates/includes/sidebar.html
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/includes/sidebar.html
+++ b/templates/includes/sidebar.html
@@ -0,0 +1,133 @@
1 <aside-label="Sidebar
2 {% endKBendfor %}
3 </div>
4 </dewBox="0 0 24 24" stroke-w-full rounded-md px-2 py-2 mb-1 text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
5 title="Expand sidebar">
6 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentCo <nav class="flex-1 px-2 py-4 space-y-1">
7
8 <!-- Collapse toggle -->
9 8.997 0 017.843 4.582M12 3a8.9= !collapsed {% if sidebarcollapsed)"
10 0-5.74-1.1-7.843-centerarCollapsed', 'false')"
11 white0 hover:text-white <aside-labeExpand sideb25 9v.776" />
12 </svg>
13 {{ entry.group.name }}
14 </div> (projectsOpen = !projectsOpen)product docs — read-only) -->
15 </svg>
16 <svg x-show="s-center gap-2">
17 h-4 w-425 9v.776" />
18 </svg>
19 {{ entry.group.name }}
20 style="display:none">
21 ont-medium {% if request.path == '/explore/' %}bg-gray-800 text-w11 viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
22 <dashboard' %}"
23 400 hover:text-white h<aside-label="Sidebar
24 {% endKBendfor %}
25 </div>
26 </dewBox="0 0 24 24" stroke-w-full rounded-md px-2 py-2 mb-1 text-gray75h3.879a1.5 1.5 075 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-label="Sidebar navigation"ass="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
27
28 <nav class="flex-1 px-2 py-3 space-yered -->
29 <button <nav class="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
30
31 <!-- Expand button (collapsed state only) — prominent, centered -->
32 <button x-show="collapsed" style="display:none"
33 @click="collapsed = false; localStorage.setItem('sidebarCollapsed', 'false')"
34 class="flex items-center justify-center w-full rounded-md px-2 py-2 mb-1 text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
35 title="Expand sidebar">
36 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
37 <path stroke-linecap="round" stroke-linejoin="round" d="M11.25 4.5l7.5 7.5-7.5 7.5m-6-15l7.5 7.5-7.5 7.5" />
38 </svg>
39 </button>
40
41 <!-- Dashboard + collapse toggle (expanded state) -->
42 <div class="flex items-center gap-1" :class="collapsed && 'justify-center'">
43 <a href="{% url 'dashboard' %}"
44 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/dashboard/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
45 :class="collapsed ? '' : 'flex-1'"
46 :title="collapsed ? 'Dashboard' : ''">
47 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
48 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 0 0021.75 18V9a2.2 12.75V12A2.25 2.25 <div x-data="{ open: '{{ project.slug }}' === '{% if request.resolver_match.kwargs.slug %}{{ request.resolver_match.kwargs.slug }}{% endif %}' }">
49 7 8.997 0 017.843 4.58open = !open"
50 1.5 text-sm {% if projectojectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
51 <span{{ project.name }}</spanllapsed = false, projectsOpen = t flex-shrink-0 stroke="currentColor">
52 o2between w-full rounded-md px-2 py- docsOpen: false </button>
53
54 <!-- Projex-show="open3 mt-0.50A9.015 9./50 pl-2">
55 projects:dean>
56 </a>
57
58 <!-- Projects sepsed = false, r
59 false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
60 class="flex items-cent (projectsOpen = !projectsOpen)1.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />ollapsed ? 'Proje Overview
61 {% withfossil:code' slug=pslug %}"an>
62 </a>
63
64 <!-- Projects sepsed = false, r
65 fossil/code 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
66 apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
67 (projectsOpen = !projectsOpen)7.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />ollapsed ? 'Proje Code
68 fossil:timeline' slug=pslug %}"an>
69 </a>
70
71 <!-- Projects sepsed = false, r
72 fossil/timeline 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
73 apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
74 (projectsOpen = !projectsOpen)"
75 v6h4.5m4.5 0a9 9 ollapsed ? 'Proje Timeline
76 fossil:tickets' slug=pslug %}"an>
77 </a>
78
79 <!-- Projects sepsed = false, r
80 fossil/tickets 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
81 apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
82 (projectsOpen = !projectsOpen)6.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026nk-0 bg-gray-900 border-r borh17.av class="flex-1 px-2 py-3 spacev-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />ollapsed ? 'Proje Tickets
83 fossil:wiki' slug=pslug %}"an>
84 </a>
85
86 <!-- Projects sepsed = false, r
87 fossil/wiki 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
88 apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
89 (projectsOpen = !projectsOpen)"
90 class="flex items-center justify-between w-full rounded-md px-2 py-2 text-sm font-medium {% if '/projects/' in request.path %}text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
91 ollapsed ? 'Proje Wiki
92 fossil:forum' slug=pslug %}"an>
93 </a>
94
95 <!-- Projects sepsed = false, r
96 fossil/forum 0A17.919 17.919 0and pjectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" s">
97 apsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
98 (projectsOpen = !projectsOpen7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />ollapsed ? 'Proje Forum
99 {% endwith %-linejoin="round" d="M2.25 5 6v12a2.25 apsed" class="truncate">Projects</span>
100 </span>
101 <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
102 <path stroke-linecap="rouTeams class="flex-1 px-2 p-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 75h3.879a1.5 1.5 0 01Teams18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 text-gray-400 hove0zm-13.5 0a2.25 2.25 0 11-4.5 0 text-gray-400 hove0zTeams</span>
103 </a <!-- Knowledge Base
104
105 <nav class="flex-1 px-2 py-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
106 </svg>
107 Knowledge Bas 12l8.954-8.955a<aside-label="Sidebar
108 {% endKBendfor %}
109 </div>
110 </dewBox="0 0 24 24" stroke-w-full rounded-md px-2 py-2 mb-1 text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
111 debar
112 {% endKBendfor %}
113 </div>
114 </dewBox="0 0 24 24" stroke-w-full rounded-md px-2 py-2 mb-1 text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
115 title="Expand sidebar">
116 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-widKnowledge Base <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
117 </svg>
118 <span x-show="!collapsed" class="truncate">Projects</span>
119 </span>
120 <svg x-show="!collapsed" class="h-4 w-4 transition-transform" :class="projectsOpen && 'rotate-90'" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
121 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
122 </svg>
123 </button>
124 <div x-show="projectsOpen && !collapsed" x-collapse class="ml-4 mt-1 space-y-0.5 border-l border-gray-700 pl-2">
125 {% for entry in sidebar_grouped %}
126 <div class="mt-2">
127 < Guid 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 Settings class="flex-1 px-2 psettings' %}"
128 400 hover:text-white hov<aside-label="Sidebar
129 and '/settings/teams/' notside-label="Sidebar<75h3.879a1.5 1.5 0 01Settings (projectsOpen = !projectsOpen)Settings</span>
130 </a <!-- Admin -->
131 admin:index' %}"
132 400 hover:text-w<aside-label="SidebarAdmin11.42 15.17l-5.385 3.171a.75.75 0 01-1.089-.791l1.414-5.816-4.534-3.89a.75.75 0 01.422-1.317l5.953-.493 2.354-5.476a.75.75 0 011.39 0l2.354 5.476 5.953.493a.75.75 0 01.422 1.317l-4.534 3.89 1.414 5.816a.75.75 0 01-1Admin</span>
133 </a</aside>
--- a/templates/items/item_confirm_delete.html
+++ b/templates/items/item_confirm_delete.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete {{ item.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'items:detail' slug=item.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ item.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-lg">
10
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11
+ <h2 class="text-lg font-semibold text-gray-100">Delete Item</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to delete <strong class="text-gray-100">{{ item.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'items:detail' slug=item.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Delete
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/items/item_confirm_delete.html
+++ b/templates/items/item_confirm_delete.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/items/item_confirm_delete.html
+++ b/templates/items/item_confirm_delete.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Delete {{ item.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'items:detail' slug=item.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ item.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-lg">
10 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11 <h2 class="text-lg font-semibold text-gray-100">Delete Item</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to delete <strong class="text-gray-100">{{ item.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'items:detail' slug=item.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Delete
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/items/item_detail.html
+++ b/templates/items/item_detail.html
@@ -0,0 +1,70 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ item.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'items:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Items</a>
7
+</div>
8
+
9
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10
+ <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
11
+ <div>
12
+ <h1 class="text-2xl font-bold text-gray-100">{{ item.name }}</h1>
13
+ <p class="mt-1 text-sm text-gray-400">{{ item.slug }}</p>
14
+ </div>
15
+ <div class="mt-4 flex gap-3 sm:mt-0">
16
+ {% if perms.items.change_item %}
17
+ <a href="{% url 'items:update' slug=item.slug %}"
18
+ class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Edit
20
+ </a>
21
+ {% endif %}
22
+ {% if perms.items.delete_item %}
23
+ <a href="{% url 'items:delete' slug=item.slug %}"
24
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
25
+ Delete
26
+ </a>
27
+ {% endif %}
28
+ </div>
29
+ </div>
30
+
31
+ <div class="border-t border-gray-700 px-6 py-5">
32
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
33
+ <div>
34
+ <dt class="text-sm font-medium text-gray-400">Price</dt>
35
+ <dd class="mt-1 text-sm text-gray-100">${{ item.price }}</dd>
36
+ </div>
37
+ <div>
38
+ <dt class="text-sm font-medium text-gray-400">SKU</dt>
39
+ <dd class="mt-1 text-sm text-gray-100">{{ item.sku|default:"—" }}</dd>
40
+ </div>
41
+ <div>
42
+ <dt class="text-sm font-medium text-gray-400">Status</dt>
43
+ <dd class="mt-1 text-sm">
44
+ {% if item.is_active %}
45
+ <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span>
46
+ {% else %}
47
+ <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span>
48
+ {% endif %}
49
+ </dd>
50
+ </div>
51
+ <div>
52
+ <dt class="text-sm font-medium text-gray-400">GUID</dt>
53
+ <dd class="mt-1 text-sm text-gray-400 font-mono">{{ item.guid }}</dd>
54
+ </div>
55
+ <div class="sm:col-span-2">
56
+ <dt class="text-sm font-medium text-gray-400">Description</dt>
57
+ <dd class="mt-1 text-sm text-gray-100">{{ item.description|default:"No description." }}</dd>
58
+ </div>
59
+ <div>
60
+ <dt class="text-sm font-medium text-gray-400">Created</dt>
61
+ <dd class="mt-1 text-sm text-gray-400">{{ item.created_at|date:"N j, Y g:i a" }} by {{ item.created_by|default:"system" }}</dd>
62
+ </div>
63
+ <div>
64
+ <dt class="text-sm font-medium text-gray-400">Updated</dt>
65
+ <dd class="mt-1 text-sm text-gray-400">{{ item.updated_at|date:"N j, Y g:i a" }}</dd>
66
+ </div>
67
+ </dl>
68
+ </div>
69
+</div>
70
+{% endblock %}
--- a/templates/items/item_detail.html
+++ b/templates/items/item_detail.html
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/items/item_detail.html
+++ b/templates/items/item_detail.html
@@ -0,0 +1,70 @@
1 {% extends "base.html" %}
2 {% block title %}{{ item.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'items:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Items</a>
7 </div>
8
9 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10 <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
11 <div>
12 <h1 class="text-2xl font-bold text-gray-100">{{ item.name }}</h1>
13 <p class="mt-1 text-sm text-gray-400">{{ item.slug }}</p>
14 </div>
15 <div class="mt-4 flex gap-3 sm:mt-0">
16 {% if perms.items.change_item %}
17 <a href="{% url 'items:update' slug=item.slug %}"
18 class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Edit
20 </a>
21 {% endif %}
22 {% if perms.items.delete_item %}
23 <a href="{% url 'items:delete' slug=item.slug %}"
24 class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
25 Delete
26 </a>
27 {% endif %}
28 </div>
29 </div>
30
31 <div class="border-t border-gray-700 px-6 py-5">
32 <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
33 <div>
34 <dt class="text-sm font-medium text-gray-400">Price</dt>
35 <dd class="mt-1 text-sm text-gray-100">${{ item.price }}</dd>
36 </div>
37 <div>
38 <dt class="text-sm font-medium text-gray-400">SKU</dt>
39 <dd class="mt-1 text-sm text-gray-100">{{ item.sku|default:"—" }}</dd>
40 </div>
41 <div>
42 <dt class="text-sm font-medium text-gray-400">Status</dt>
43 <dd class="mt-1 text-sm">
44 {% if item.is_active %}
45 <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span>
46 {% else %}
47 <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span>
48 {% endif %}
49 </dd>
50 </div>
51 <div>
52 <dt class="text-sm font-medium text-gray-400">GUID</dt>
53 <dd class="mt-1 text-sm text-gray-400 font-mono">{{ item.guid }}</dd>
54 </div>
55 <div class="sm:col-span-2">
56 <dt class="text-sm font-medium text-gray-400">Description</dt>
57 <dd class="mt-1 text-sm text-gray-100">{{ item.description|default:"No description." }}</dd>
58 </div>
59 <div>
60 <dt class="text-sm font-medium text-gray-400">Created</dt>
61 <dd class="mt-1 text-sm text-gray-400">{{ item.created_at|date:"N j, Y g:i a" }} by {{ item.created_by|default:"system" }}</dd>
62 </div>
63 <div>
64 <dt class="text-sm font-medium text-gray-400">Updated</dt>
65 <dd class="mt-1 text-sm text-gray-400">{{ item.updated_at|date:"N j, Y g:i a" }}</dd>
66 </div>
67 </dl>
68 </div>
69 </div>
70 {% endblock %}
--- a/templates/items/item_form.html
+++ b/templates/items/item_form.html
@@ -0,0 +1,42 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'items:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Items</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ {% if field.help_text %}
25
+ <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p>
26
+ {% endif %}
27
+ </div>
28
+ {% endfor %}
29
+
30
+ <div class="flex justify-end gap-3 pt-4">
31
+ <a href="{% url 'items:list' %}"
32
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
33
+ Cancel
34
+ </a>
35
+ <button type="submit"
36
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
37
+ {% if item %}Update{% else %}Create{% endif %}
38
+ </button>
39
+ </div>
40
+ </form>
41
+</div>
42
+{% endblock %}
--- a/templates/items/item_form.html
+++ b/templates/items/item_form.html
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/items/item_form.html
+++ b/templates/items/item_form.html
@@ -0,0 +1,42 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'items:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Items</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 {% if field.help_text %}
25 <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p>
26 {% endif %}
27 </div>
28 {% endfor %}
29
30 <div class="flex justify-end gap-3 pt-4">
31 <a href="{% url 'items:list' %}"
32 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
33 Cancel
34 </a>
35 <button type="submit"
36 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
37 {% if item %}Update{% else %}Create{% endif %}
38 </button>
39 </div>
40 </form>
41 </div>
42 {% endblock %}
--- a/templates/items/item_list.html
+++ b/templates/items/item_list.html
@@ -0,0 +1,29 @@
1
+{% extends "base.html" %}
2
+{% block title %}Items — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="md:flex md:items-center md:justify-between mb-6">
6
+ <h1 class="text-2xl font-bold text-gray-100">Items</h1>
7
+ {% if perms.items.add_item %}
8
+ <a href="{% url 'items:create' %}"
9
+ class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
10
+ New Item
11
+ </a>
12
+ {% endif %}
13
+</div>
14
+
15
+<div class="mb-4">
16
+ <input type="search"
17
+ name="search"
18
+ value="{{ search }}"
19
+ placeholder="Search items..."
20
+ class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
21
+ hx-get="{% url 'items:list' %}"
22
+ hx-trigger="input changed delay:300ms, search"
23
+ hx-target="#item-table"
24
+ hx-swap="outerHTML"
25
+ hx-push-url="true" />
26
+</div>
27
+
28
+{% include "items/partials/item_table.html" %}
29
+{% endblock %}
--- a/templates/items/item_list.html
+++ b/templates/items/item_list.html
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/items/item_list.html
+++ b/templates/items/item_list.html
@@ -0,0 +1,29 @@
1 {% extends "base.html" %}
2 {% block title %}Items — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="md:flex md:items-center md:justify-between mb-6">
6 <h1 class="text-2xl font-bold text-gray-100">Items</h1>
7 {% if perms.items.add_item %}
8 <a href="{% url 'items:create' %}"
9 class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
10 New Item
11 </a>
12 {% endif %}
13 </div>
14
15 <div class="mb-4">
16 <input type="search"
17 name="search"
18 value="{{ search }}"
19 placeholder="Search items..."
20 class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
21 hx-get="{% url 'items:list' %}"
22 hx-trigger="input changed delay:300ms, search"
23 hx-target="#item-table"
24 hx-swap="outerHTML"
25 hx-push-url="true" />
26 </div>
27
28 {% include "items/partials/item_table.html" %}
29 {% endblock %}
--- a/templates/items/partials/item_table.html
+++ b/templates/items/partials/item_table.html
@@ -0,0 +1,44 @@
1
+<div id="item-table">
2
+ <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
3
+ <table class="min-w-full divide-y divide-gray-700">
4
+ <thead class="bg-gray-900">
5
+ <tr>
6
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Name</th>
7
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">SKU</th>
8
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Price</th>
9
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Status</th>
10
+ <th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-400">Actions</th>
11
+ </tr>
12
+ </thead>
13
+ <tbody class="divide-y divide-gray-700 bg-gray-800">
14
+ {% for item in items %}
15
+ <tr class="hover:bg-gray-700/50">
16
+ <td class="px-6 py-4 whitespace-nowrap">
17
+ <a href="{% url 'items:detail' slug=item.slug %}" class="text-brand-light hover:text-brand font-medium">
18
+ {{ item.name }}
19
+ </a>
20
+ </td>
21
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ item.sku|default:"—" }}</td>
22
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100">${{ item.price }}</td>
23
+ <td class="px-6 py-4 whitespace-nowrap">
24
+ {% if item.is_active %}
25
+ <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span>
26
+ {% else %}
27
+ <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span>
28
+ {% endif %}
29
+ </td>
30
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
31
+ {% if perms.items.change_item %}
32
+ <a href="{% url 'items:update' slug=item.slug %}" class="text-brand-light hover:text-brand">Edit</a>
33
+ {% endif %}
34
+ </td>
35
+ </tr>
36
+ {% empty %}
37
+ <tr>
38
+ <td colspan="5" class="px-6 py-8 text-center text-sm text-gray-400">No items found.</td>
39
+ </tr>
40
+ {% endfor %}
41
+ </tbody>
42
+ </table>
43
+ </div>
44
+</div>
--- a/templates/items/partials/item_table.html
+++ b/templates/items/partials/item_table.html
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/items/partials/item_table.html
+++ b/templates/items/partials/item_table.html
@@ -0,0 +1,44 @@
1 <div id="item-table">
2 <div class="overflow-hidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
3 <table class="min-w-full divide-y divide-gray-700">
4 <thead class="bg-gray-900">
5 <tr>
6 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Name</th>
7 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">SKU</th>
8 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Price</th>
9 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Status</th>
10 <th class="px-6 py-3 text-right text-xs font-medium uppercase text-gray-400">Actions</th>
11 </tr>
12 </thead>
13 <tbody class="divide-y divide-gray-700 bg-gray-800">
14 {% for item in items %}
15 <tr class="hover:bg-gray-700/50">
16 <td class="px-6 py-4 whitespace-nowrap">
17 <a href="{% url 'items:detail' slug=item.slug %}" class="text-brand-light hover:text-brand font-medium">
18 {{ item.name }}
19 </a>
20 </td>
21 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ item.sku|default:"—" }}</td>
22 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-100">${{ item.price }}</td>
23 <td class="px-6 py-4 whitespace-nowrap">
24 {% if item.is_active %}
25 <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Active</span>
26 {% else %}
27 <span class="inline-flex rounded-full bg-gray-700 px-2 text-xs font-semibold leading-5 text-gray-300">Inactive</span>
28 {% endif %}
29 </td>
30 <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
31 {% if perms.items.change_item %}
32 <a href="{% url 'items:update' slug=item.slug %}" class="text-brand-light hover:text-brand">Edit</a>
33 {% endif %}
34 </td>
35 </tr>
36 {% empty %}
37 <tr>
38 <td colspan="5" class="px-6 py-8 text-center text-sm text-gray-400">No items found.</td>
39 </tr>
40 {% endfor %}
41 </tbody>
42 </table>
43 </div>
44 </div>
--- a/templates/organization/member_add.html
+++ b/templates/organization/member_add.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Add Member — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'organization:members' %}"
29
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Add Member
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/organization/member_add.html
+++ b/templates/organization/member_add.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/member_add.html
+++ b/templates/organization/member_add.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Add Member — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'organization:members' %}"
29 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Add Member
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/organization/member_confirm_remove.html
+++ b/templates/organization/member_confirm_remove.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Remove Member — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-lg">
10
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11
+ <h2 class="text-lg font-semibold text-gray-100">Remove Member</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to remove <strong class="text-gray-100">{{ membership.member.username }}</strong> from the organization? This action uses soft delete — the membership can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'organization:members' %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Remove
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/organization/member_confirm_remove.html
+++ b/templates/organization/member_confirm_remove.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/member_confirm_remove.html
+++ b/templates/organization/member_confirm_remove.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Remove Member — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:members' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Members</a>
7 </div>
8
9 <div class="mx-auto max-w-lg">
10 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11 <h2 class="text-lg font-semibold text-gray-100">Remove Member</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to remove <strong class="text-gray-100">{{ membership.member.username }}</strong> from the organization? This action uses soft delete — the membership can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'organization:members' %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Remove
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/organization/member_list.html
+++ b/templates/organization/member_list.html
@@ -0,0 +1,30 @@
1
+{% extends "base.html" %}
2
+{% block title %}Members — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7
+</div>
8
+
9
+<div class="md:flex md:items-center md:justify-between mb-6">
10
+ <h1 class="text-2xl font-bold tex{% if perms.organization.ar user.is_superuser %}
11
+ <a member_add' %}"
12
+ ay-100">Members</h1>
13
+_create' %}"
14
+ class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semAdd Member
15
+ </a>
16
+ {% endif %}
17
+ <input type="search"
18
+ name="search"
19
+ value="{{ search }}"
20
+ placeholder="Search members..."
21
+ class="w-full max-w-mders"
22
+ class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
23
+ hx-get="{% url 'organization:members' %}"
24
+ hx-trigger="input changed delay:300ms, search"
25
+ hx-target="#member-table"
26
+ />
27
+</div>
28
+
29
+{% include "organization/partials/member_table.html" %}
30
+{% include "includes/_paginatiendblock %}
--- a/templates/organization/member_list.html
+++ b/templates/organization/member_list.html
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/member_list.html
+++ b/templates/organization/member_list.html
@@ -0,0 +1,30 @@
1 {% extends "base.html" %}
2 {% block title %}Members — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7 </div>
8
9 <div class="md:flex md:items-center md:justify-between mb-6">
10 <h1 class="text-2xl font-bold tex{% if perms.organization.ar user.is_superuser %}
11 <a member_add' %}"
12 ay-100">Members</h1>
13 _create' %}"
14 class="inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semAdd Member
15 </a>
16 {% endif %}
17 <input type="search"
18 name="search"
19 value="{{ search }}"
20 placeholder="Search members..."
21 class="w-full max-w-mders"
22 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
23 hx-get="{% url 'organization:members' %}"
24 hx-trigger="input changed delay:300ms, search"
25 hx-target="#member-table"
26 />
27 </div>
28
29 {% include "organization/partials/member_table.html" %}
30 {% include "includes/_paginatiendblock %}
--- a/templates/organization/partials/member_table.html
+++ b/templates/organization/partials/member_table.html
@@ -0,0 +1,18 @@
1
+<div id="member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">table">
4
+ <div class="over<<div id="member-table">
5
+ <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
6
+ <table class="min-w-full divide-y divide-gray-700">
7
+ <thead class="bg-gray-900/80">
8
+ <tr>
9
+ <th class="px-6Status>
10
+ <th0">Username</th>
11
+ il</th>
12
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Role</th>
13
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Status</th>
14
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Joined</th>
15
+ text-gray-100">
16
+ 5"s="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-400">Actions</th>
17
+ </tr>
18
+
--- a/templates/organization/partials/member_table.html
+++ b/templates/organization/partials/member_table.html
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/partials/member_table.html
+++ b/templates/organization/partials/member_table.html
@@ -0,0 +1,18 @@
1 <div id="member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">table">
4 <div class="over<<div id="member-table">
5 <div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
6 <table class="min-w-full divide-y divide-gray-700">
7 <thead class="bg-gray-900/80">
8 <tr>
9 <th class="px-6Status>
10 <th0">Username</th>
11 il</th>
12 <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Role</th>
13 <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Status</th>
14 <th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-400">Joined</th>
15 text-gray-100">
16 5"s="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-400">Actions</th>
17 </tr>
18
--- a/templates/organization/partials/team_member_table.html
+++ b/templates/organization/partials/team_member_table.html
@@ -0,0 +1,21 @@
1
+<div id="team-member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Email</th>
6
+ <th class="px-6 py-3 text-right text bg-gray-800">
7
+ {% for member in team_members %}
8
+ <tr class="hover:bg-gray-700/50">
9
+ <td class="px-6 psm font-medium nowrap text-sm font-medium text-gray-100">{{ member.username }}</td>
10
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ member.email|default:"—" }}</td>
11
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
12
+ {% if perms.organization.change_team %}
13
+ <a href="{% url 'organization:team_member_remove' slug=team.slug username=member.username %}" class="text-red-400 hover:text-red-300">Remove</a>
14
+ {% endif %}
15
+ </td>
16
+ </tr>
17
+ {% empty %}
18
+ <tr>
19
+ <td colspan="3" class="px-6 py-8 text-center text-sm text-gray-400">No members yet.</td>
20
+ </tr>
21
+ {%
--- a/templates/organization/partials/team_member_table.html
+++ b/templates/organization/partials/team_member_table.html
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/partials/team_member_table.html
+++ b/templates/organization/partials/team_member_table.html
@@ -0,0 +1,21 @@
1 <div id="team-member-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Email</th>
6 <th class="px-6 py-3 text-right text bg-gray-800">
7 {% for member in team_members %}
8 <tr class="hover:bg-gray-700/50">
9 <td class="px-6 psm font-medium nowrap text-sm font-medium text-gray-100">{{ member.username }}</td>
10 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ member.email|default:"—" }}</td>
11 <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
12 {% if perms.organization.change_team %}
13 <a href="{% url 'organization:team_member_remove' slug=team.slug username=member.username %}" class="text-red-400 hover:text-red-300">Remove</a>
14 {% endif %}
15 </td>
16 </tr>
17 {% empty %}
18 <tr>
19 <td colspan="3" class="px-6 py-8 text-center text-sm text-gray-400">No members yet.</td>
20 </tr>
21 {%
--- a/templates/organization/partials/team_table.html
+++ b/templates/organization/partials/team_table.html
@@ -0,0 +1,26 @@
1
+<div id="team-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <th class="px-6 py-3 text-left text uppercase tracking-wider text-gray-400">Name</th>
6
+ <th class="px-6 py-3 text-left textext-gray-400">Members</th>
7
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800">
8
+ {% for team in teams %}
9
+ <t50">
10
+ <td class=""">Action <a href=detail' slug=team.slug %}" class="text-brand-light hover:text-brand font-medium">
11
+ {{ team.name }}
12
+ </a>
13
+ </td>
14
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.members.count }}</td>
15
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.created_at|date:"N j, Y" }}</td>
16
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
17
+ {% if perms.organization.change_team %}
18
+ <a href="{% url 'organization:team_update' slug=team.slug %}" class="text-brand-light hover:text-brand">Edit</a>
19
+ {% endif %}
20
+ </td>
21
+ </tr>
22
+ {% empty %}
23
+ <tr>
24
+ <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No teams found.</td>
25
+ </tr>
26
+ {%
--- a/templates/organization/partials/team_table.html
+++ b/templates/organization/partials/team_table.html
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/partials/team_table.html
+++ b/templates/organization/partials/team_table.html
@@ -0,0 +1,26 @@
1 <div id="team-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <th class="px-6 py-3 text-left text uppercase tracking-wider text-gray-400">Name</th>
6 <th class="px-6 py-3 text-left textext-gray-400">Members</th>
7 <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800">
8 {% for team in teams %}
9 <t50">
10 <td class=""">Action <a href=detail' slug=team.slug %}" class="text-brand-light hover:text-brand font-medium">
11 {{ team.name }}
12 </a>
13 </td>
14 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.members.count }}</td>
15 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ team.created_at|date:"N j, Y" }}</td>
16 <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
17 {% if perms.organization.change_team %}
18 <a href="{% url 'organization:team_update' slug=team.slug %}" class="text-brand-light hover:text-brand">Edit</a>
19 {% endif %}
20 </td>
21 </tr>
22 {% empty %}
23 <tr>
24 <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No teams found.</td>
25 </tr>
26 {%
--- a/templates/organization/settings.html
+++ b/templates/organization/settings.html
@@ -0,0 +1,32 @@
1
+{% extends "base.html" %}
2
+{% block title %}Settings — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="md:flex md:items-center md:justify-between mb-6">
6
+ <h1 class="text-2xl font-bold text-gray-100">Organization Settings</h1>
7
+ {% if perms.organization.change_organization %}
8
+ <a href="{% url 'organization:settings_edit' %}"
9
+ class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
10
+ Edit Settings
11
+ </a>
12
+ {% endif %}
13
+</div>
14
+
15
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
16
+ <div class="px-6 py-5">
17
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
18
+ <div>
19
+ <dt class="text-sm font-medium text-gray-400">Name</dt>
20
+ <dd class="mt-1 text-sm text-gray-100">{{ org.name }}</dd>
21
+ </div>
22
+ <div>
23
+ <dt class="text-sm font-medium text-gray-400">Slug</dt>
24
+ <dd class="mt-1 text-sm text-gray-400 font-mono">{{ org.slug }}</dd>
25
+ </div>
26
+ <div>
27
+ <dt class="text-sm font-medium text-gray-400">Website</dt>
28
+ <dd class="mt-1 text-sm text-gray-100">{{ org.website|default:"—" }}</dd>
29
+ </div>
30
+ <div>
31
+ <dt class="text-sm font-medium text-gray-400">GUID</dt>
32
+ <dd class="mt-1 text-sm{% endblock %}
--- a/templates/organization/settings.html
+++ b/templates/organization/settings.html
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/settings.html
+++ b/templates/organization/settings.html
@@ -0,0 +1,32 @@
1 {% extends "base.html" %}
2 {% block title %}Settings — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="md:flex md:items-center md:justify-between mb-6">
6 <h1 class="text-2xl font-bold text-gray-100">Organization Settings</h1>
7 {% if perms.organization.change_organization %}
8 <a href="{% url 'organization:settings_edit' %}"
9 class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
10 Edit Settings
11 </a>
12 {% endif %}
13 </div>
14
15 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
16 <div class="px-6 py-5">
17 <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
18 <div>
19 <dt class="text-sm font-medium text-gray-400">Name</dt>
20 <dd class="mt-1 text-sm text-gray-100">{{ org.name }}</dd>
21 </div>
22 <div>
23 <dt class="text-sm font-medium text-gray-400">Slug</dt>
24 <dd class="mt-1 text-sm text-gray-400 font-mono">{{ org.slug }}</dd>
25 </div>
26 <div>
27 <dt class="text-sm font-medium text-gray-400">Website</dt>
28 <dd class="mt-1 text-sm text-gray-100">{{ org.website|default:"—" }}</dd>
29 </div>
30 <div>
31 <dt class="text-sm font-medium text-gray-400">GUID</dt>
32 <dd class="mt-1 text-sm{% endblock %}
--- a/templates/organization/settings_form.html
+++ b/templates/organization/settings_form.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Edit Settings — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit Organization Settings</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'organization:settings' %}"
29
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Save
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/organization/settings_form.html
+++ b/templates/organization/settings_form.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/settings_form.html
+++ b/templates/organization/settings_form.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Edit Settings — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit Organization Settings</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'organization:settings' %}"
29 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Save
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/organization/team_confirm_delete.html
+++ b/templates/organization/team_confirm_delete.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete {{ team.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-lg">
10
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11
+ <h2 class="text-lg font-semibold text-gray-100">Delete Team</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to delete <strong class="text-gray-100">{{ team.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'organization:team_detail' slug=team.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Delete
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/organization/team_confirm_delete.html
+++ b/templates/organization/team_confirm_delete.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_confirm_delete.html
+++ b/templates/organization/team_confirm_delete.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Delete {{ team.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-lg">
10 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11 <h2 class="text-lg font-semibold text-gray-100">Delete Team</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to delete <strong class="text-gray-100">{{ team.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'organization:team_detail' slug=team.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Delete
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/organization/team_detail.html
+++ b/templates/organization/team_detail.html
@@ -0,0 +1,62 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ team.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Teams</a>
7
+</div>
8
+
9
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10
+ <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
11
+ <div>
12
+ <h1 class="text-2xl font-bold text-gray-100">{{ team.name }}</h1>
13
+ <p class="mt-1 text-sm text-gray-400">{{ team.slug }}</p>
14
+ </div>
15
+ <div class="mt-4 flex gap-3 sm:mt-0">
16
+ {% if perms.organization.change_team %}
17
+ <a href="{% url 'organization:team_update' slug=team.slug %}"
18
+ class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Edit
20
+ </a>
21
+ {% endif %}
22
+ {% if perms.organization.delete_team %}
23
+ <a href="{% url 'organization:team_delete' slug=team.slug %}"
24
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
25
+ Delete
26
+ </a>
27
+ {% endif %}
28
+ </div>
29
+ </div>
30
+
31
+ <div class="border-t border-gray-700 px-6 py-5">
32
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
33
+ <div class="sm:col-span-2">
34
+ <dt class="text-sm font-medium text-gray-400">Description</dt>
35
+ <dd class="mt-1 text-sm text-gray-100">{{ team.description|default:"No description." }}</dd>
36
+ </div>
37
+ <div>
38
+ <dt class="text-sm font-medium text-gray-400">GUID</dt>
39
+ <dd class="mt-1 text-sm text-gray-400 font-mono">{{ team.guid }}</dd>
40
+ </div>
41
+ <div>
42
+ <dt class="text-sm font-medium text-gray-400">Created</dt>
43
+ <dd class="mt-1 text-sm text-gray-400">{{ team.created_at|date:"N j, Y g:i a" }} by {{ team.created_by|default:"system" }}</dd>
44
+ </div>
45
+ </dl>
46
+ </div>
47
+</div>
48
+
49
+<div class="mt-8">
50
+ <div class="md:flex md:items-center md:justify-between mb-4">
51
+ <h2 class="text-lg font-semibold text-gray-100">Team Members</h2>
52
+ {% if perms.organization.change_team %}
53
+ <a href="{% url 'organization:team_member_add' slug=team.slug %}"
54
+ class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
55
+ Add Member
56
+ </a>
57
+ {% endif %}
58
+ </div>
59
+
60
+ {% include "organization/partials/team_member_table.html" %}
61
+</div>
62
+{% endblock %}
--- a/templates/organization/team_detail.html
+++ b/templates/organization/team_detail.html
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_detail.html
+++ b/templates/organization/team_detail.html
@@ -0,0 +1,62 @@
1 {% extends "base.html" %}
2 {% block title %}{{ team.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Teams</a>
7 </div>
8
9 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
10 <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between">
11 <div>
12 <h1 class="text-2xl font-bold text-gray-100">{{ team.name }}</h1>
13 <p class="mt-1 text-sm text-gray-400">{{ team.slug }}</p>
14 </div>
15 <div class="mt-4 flex gap-3 sm:mt-0">
16 {% if perms.organization.change_team %}
17 <a href="{% url 'organization:team_update' slug=team.slug %}"
18 class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Edit
20 </a>
21 {% endif %}
22 {% if perms.organization.delete_team %}
23 <a href="{% url 'organization:team_delete' slug=team.slug %}"
24 class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
25 Delete
26 </a>
27 {% endif %}
28 </div>
29 </div>
30
31 <div class="border-t border-gray-700 px-6 py-5">
32 <dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
33 <div class="sm:col-span-2">
34 <dt class="text-sm font-medium text-gray-400">Description</dt>
35 <dd class="mt-1 text-sm text-gray-100">{{ team.description|default:"No description." }}</dd>
36 </div>
37 <div>
38 <dt class="text-sm font-medium text-gray-400">GUID</dt>
39 <dd class="mt-1 text-sm text-gray-400 font-mono">{{ team.guid }}</dd>
40 </div>
41 <div>
42 <dt class="text-sm font-medium text-gray-400">Created</dt>
43 <dd class="mt-1 text-sm text-gray-400">{{ team.created_at|date:"N j, Y g:i a" }} by {{ team.created_by|default:"system" }}</dd>
44 </div>
45 </dl>
46 </div>
47 </div>
48
49 <div class="mt-8">
50 <div class="md:flex md:items-center md:justify-between mb-4">
51 <h2 class="text-lg font-semibold text-gray-100">Team Members</h2>
52 {% if perms.organization.change_team %}
53 <a href="{% url 'organization:team_member_add' slug=team.slug %}"
54 class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
55 Add Member
56 </a>
57 {% endif %}
58 </div>
59
60 {% include "organization/partials/team_member_table.html" %}
61 </div>
62 {% endblock %}
--- a/templates/organization/team_form.html
+++ b/templates/organization/team_form.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Teams</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'organization:team_list' %}"
29
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ {% if team %}Update{% else %}Create{% endif %}
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/organization/team_form.html
+++ b/templates/organization/team_form.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_form.html
+++ b/templates/organization/team_form.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Teams</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'organization:team_list' %}"
29 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 {% if team %}Update{% else %}Create{% endif %}
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/organization/team_list.html
+++ b/templates/organization/team_list.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Teams — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7
+</div>
8
+
9
+<div class="md:flex md:items-center md:justify-between mb-6">
10
+ <h1 class="text-2xl font-bold text-gray-100">Teams</h1>
11
+ {% if perms.organization.add_team %}
12
+ <a href="{% url 'organization:team_create' %}"
13
+ class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
14
+ New Team
15
+ </a>
16
+ {% endif %}
17
+</div>
18
+
19
+<div class="mb-4class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
20
+ hx-get="{% url 'organization:team_list' %}"
21
+ hx-trigger="input changed delay:300ms, search"
22
+ hx-target="#team-table"
23
+ hx-swap="outerHTML"
24
+ />
25
+</div>
26
+
27
+{% include "organization/partials/team_table.html" %}
28
+{% include "includes/_paginatiendblock %}
--- a/templates/organization/team_list.html
+++ b/templates/organization/team_list.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_list.html
+++ b/templates/organization/team_list.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Teams — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:settings' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Settings</a>
7 </div>
8
9 <div class="md:flex md:items-center md:justify-between mb-6">
10 <h1 class="text-2xl font-bold text-gray-100">Teams</h1>
11 {% if perms.organization.add_team %}
12 <a href="{% url 'organization:team_create' %}"
13 class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
14 New Team
15 </a>
16 {% endif %}
17 </div>
18
19 <div class="mb-4class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
20 hx-get="{% url 'organization:team_list' %}"
21 hx-trigger="input changed delay:300ms, search"
22 hx-target="#team-table"
23 hx-swap="outerHTML"
24 />
25 </div>
26
27 {% include "organization/partials/team_table.html" %}
28 {% include "includes/_paginatiendblock %}
--- a/templates/organization/team_member_add.html
+++ b/templates/organization/team_member_add.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Add Member to {{ team.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member to {{ team.name }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'organization:team_detail' slug=team.slug %}"
29
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Add Member
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/organization/team_member_add.html
+++ b/templates/organization/team_member_add.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_member_add.html
+++ b/templates/organization/team_member_add.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Add Member to {{ team.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Member to {{ team.name }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'organization:team_detail' slug=team.slug %}"
29 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Add Member
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/organization/team_member_confirm_remove.html
+++ b/templates/organization/team_member_confirm_remove.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Remove Member — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-lg">
10
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11
+ <h2 class="text-lg font-semibold text-gray-100">Remove Member</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to remove <strong class="text-gray-100">{{ member_user.username }}</strong> from <strong class="text-gray-100">{{ team.name }}</strong>?
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'organization:team_detail' slug=team.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Remove
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/organization/team_member_confirm_remove.html
+++ b/templates/organization/team_member_confirm_remove.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/team_member_confirm_remove.html
+++ b/templates/organization/team_member_confirm_remove.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Remove Member — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'organization:team_detail' slug=team.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ team.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-lg">
10 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11 <h2 class="text-lg font-semibold text-gray-100">Remove Member</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to remove <strong class="text-gray-100">{{ member_user.username }}</strong> from <strong class="text-gray-100">{{ team.name }}</strong>?
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'organization:team_detail' slug=team.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Remove
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/pages/page_confirm_delete.html
+++ b/templates/pages/page_confirm_delete.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete {{ page.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'pages:detail' slug=page.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ page.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-lg">
10
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11
+ <h2 class="text-lg font-semibold text-gray-100">Delete Page</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to delete <strong class="text-gray-100">{{ page.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'pages:detail' slug=page.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Delete
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/pages/page_confirm_delete.html
+++ b/templates/pages/page_confirm_delete.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/page_confirm_delete.html
+++ b/templates/pages/page_confirm_delete.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Delete {{ page.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'pages:detail' slug=page.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ page.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-lg">
10 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11 <h2 class="text-lg font-semibold text-gray-100">Delete Page</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to delete <strong class="text-gray-100">{{ page.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'pages:detail' slug=page.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Delete
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/pages/page_detail.html
+++ b/templates/pages/page_detail.html
@@ -0,0 +1,38 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ page.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
6
+ <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between border-b border-gray-700">
7
+ <div>
8
+ <h1 class="text-2xl font-bold text-gray-100">{{ page.name }}</h1>
9
+ <p class="mt-1 text-sm text-gray-400">
10
+ {% if not page.is_published %}
11
+ <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300 mr-2">Draft</span>
12
+ {% endif %}
13
+ Updated {{ page.updated_at|date:"N j, Y g:i a" }}{% if page.updated_by %} by {{ page.updated_by }}{% endif %}
14
+ </p>
15
+ </div>
16
+ <div class="mt-4 flex gap-3 sm:mt-0">
17
+ {% if perms.pages.change_page %}
18
+ <a href="{% url 'pages:update' slug=page.slug %}"
19
+ class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
20
+ Edit
21
+ </a>
22
+ {% endif %}
23
+ {% if perms.pages.delete_page %}
24
+ <a href="{% url 'pages:delete' slug=page.slug %}"
25
+ class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
26
+ Delete
27
+ </a>
28
+ {% endif %}
29
+ </div>
30
+ </div>
31
+
32
+ <div class="px-6 py-6">
33
+ <div class="prose prose-invert prose-gray max-w-none">
34
+ {{ content_html }}
35
+ </div>
36
+ </div>
37
+</div>
38
+{% endblock %}
--- a/templates/pages/page_detail.html
+++ b/templates/pages/page_detail.html
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/page_detail.html
+++ b/templates/pages/page_detail.html
@@ -0,0 +1,38 @@
1 {% extends "base.html" %}
2 {% block title %}{{ page.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
6 <div class="px-6 py-5 sm:flex sm:items-center sm:justify-between border-b border-gray-700">
7 <div>
8 <h1 class="text-2xl font-bold text-gray-100">{{ page.name }}</h1>
9 <p class="mt-1 text-sm text-gray-400">
10 {% if not page.is_published %}
11 <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300 mr-2">Draft</span>
12 {% endif %}
13 Updated {{ page.updated_at|date:"N j, Y g:i a" }}{% if page.updated_by %} by {{ page.updated_by }}{% endif %}
14 </p>
15 </div>
16 <div class="mt-4 flex gap-3 sm:mt-0">
17 {% if perms.pages.change_page %}
18 <a href="{% url 'pages:update' slug=page.slug %}"
19 class="rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
20 Edit
21 </a>
22 {% endif %}
23 {% if perms.pages.delete_page %}
24 <a href="{% url 'pages:delete' slug=page.slug %}"
25 class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
26 Delete
27 </a>
28 {% endif %}
29 </div>
30 </div>
31
32 <div class="px-6 py-6">
33 <div class="prose prose-invert prose-gray max-w-none">
34 {{ content_html }}
35 </div>
36 </div>
37 </div>
38 {% endblock %}
--- a/templates/pages/page_form.html
+++ b/templates/pages/page_form.html
@@ -0,0 +1,40 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand"KBrr; Back to FoKnowledge Baserr; Back to FossilRepo Docs</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-4xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ {% if field.help_text %}
25
+ <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p>
26
+ {% endif %}
27
+ </div>
28
+ {% endfor %}
29
+
30
+ <div class="flex justify-end gap-3 pt-4">
31
+ <a href="{% url 'pages:list' %}"
32
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
33
+ Cancel
34
+ </a>
35
+ <button type="submit"
36
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
37
+ {% if page %}Update{% else %}Create{% endif %}
38
+ </button>
39
+ </div>
40
+ </f
--- a/templates/pages/page_form.html
+++ b/templates/pages/page_form.html
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/page_form.html
+++ b/templates/pages/page_form.html
@@ -0,0 +1,40 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'pages:list' %}" class="text-sm text-brand-light hover:text-brand"KBrr; Back to FoKnowledge Baserr; Back to FossilRepo Docs</a>
7 </div>
8
9 <div class="mx-auto max-w-4xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 {% if field.help_text %}
25 <p class="mt-1 text-sm text-gray-400">{{ field.help_text }}</p>
26 {% endif %}
27 </div>
28 {% endfor %}
29
30 <div class="flex justify-end gap-3 pt-4">
31 <a href="{% url 'pages:list' %}"
32 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
33 Cancel
34 </a>
35 <button type="submit"
36 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
37 {% if page %}Update{% else %}Create{% endif %}
38 </button>
39 </div>
40 </f
--- a/templates/pages/page_list.html
+++ b/templates/pages/page_list.html
@@ -0,0 +1,28 @@
1
+{% extends "baseKnowledge Baseock title %}FossilRepo Docs — Fossilrepo{% endblock %}
2
+
3
+{% block content %}
4
+<div class="md:flex md:items-center md:justify-between mb-6">
5
+ <hKBt-gray-100">FoKnowledge Base</h1>
6
+ {% if perms.pages.add_page %}
7
+ <a href="{% url 'pages:create' %}"
8
+ class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
9
+ New Page
10
+ </a>
11
+ {% endif %}
12
+</div>
13
+
14
+<div class="mb-4">
15
+ <input type="search"
16
+ name="search"
17
+ value="{{ search }}"
18
+ placeholder="Search knowledge base..."
19
+ class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
20
+ hx-get="{% url 'pages:list' %}"
21
+ hx-trigger="input changed delay:300ms, search"
22
+ hx-target="#page-table"
23
+ hx-swap="outerHTML"
24
+ />
25
+</div>
26
+
27
+{% include "pages/partials/page_table.html" %}
28
+{% include "includes/_paginatiendblock %}
--- a/templates/pages/page_list.html
+++ b/templates/pages/page_list.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/page_list.html
+++ b/templates/pages/page_list.html
@@ -0,0 +1,28 @@
1 {% extends "baseKnowledge Baseock title %}FossilRepo Docs — Fossilrepo{% endblock %}
2
3 {% block content %}
4 <div class="md:flex md:items-center md:justify-between mb-6">
5 <hKBt-gray-100">FoKnowledge Base</h1>
6 {% if perms.pages.add_page %}
7 <a href="{% url 'pages:create' %}"
8 class="mt-4 md:mt-0 inline-flex items-center rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
9 New Page
10 </a>
11 {% endif %}
12 </div>
13
14 <div class="mb-4">
15 <input type="search"
16 name="search"
17 value="{{ search }}"
18 placeholder="Search knowledge base..."
19 class="w-full max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm"
20 hx-get="{% url 'pages:list' %}"
21 hx-trigger="input changed delay:300ms, search"
22 hx-target="#page-table"
23 hx-swap="outerHTML"
24 />
25 </div>
26
27 {% include "pages/partials/page_table.html" %}
28 {% include "includes/_paginatiendblock %}
--- a/templates/pages/partials/page_table.html
+++ b/templates/pages/partials/page_table.html
@@ -0,0 +1,37 @@
1
+<div id="page-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Title</th>
6
+ <th class="px-6 py-3 text-left textext-gray-400">Status</th>
7
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800">
8
+ {% for page in pages %}
9
+ <t50">
10
+ 6 py-4 wh<td class="px-6 py-4 whitespace-nowrap">
11
+ <a href="{% url 'pages:detail' slug=page.slug %}" class="text-brand-light hover:text-brand font-medium">
12
+ {{ page.name }}
13
+ </a>
14
+ </td>
15
+ <td class="px-6 py-4 whitespace-nowrap">
16
+ {% if page.is_published %}
17
+ <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Published</span>
18
+ {% else %}
19
+ <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300">Draft</span>
20
+ {% endif %}
21
+ </td>
22
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ page.updated_at|date:"N j, Y" }}</td>
23
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
24
+ {% if perms.pages.change_page %}
25
+ <a href="{% url 'pages:update' slug=page.slug %}" class="text-brand-light hover:text-brand">Edit</a>
26
+ {% endif %}
27
+ </td>
28
+ </tr>
29
+ {% empty %}
30
+ <tr>
31
+ <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No pages found.</td>
32
+ </tr>
33
+ {% endfor %}
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+</div>
--- a/templates/pages/partials/page_table.html
+++ b/templates/pages/partials/page_table.html
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/pages/partials/page_table.html
+++ b/templates/pages/partials/page_table.html
@@ -0,0 +1,37 @@
1 <div id="page-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <th class="px-6 py-3 text-left text-xs font-medium uppercase t uppercase tracking-wider text-gray-400">Title</th>
6 <th class="px-6 py-3 text-left textext-gray-400">Status</th>
7 <th class="px-6 py-3 text-left text-xs font-medium uppercase tdivide-y divide-gray-700/70 bg-gray-800">
8 {% for page in pages %}
9 <t50">
10 6 py-4 wh<td class="px-6 py-4 whitespace-nowrap">
11 <a href="{% url 'pages:detail' slug=page.slug %}" class="text-brand-light hover:text-brand font-medium">
12 {{ page.name }}
13 </a>
14 </td>
15 <td class="px-6 py-4 whitespace-nowrap">
16 {% if page.is_published %}
17 <span class="inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300">Published</span>
18 {% else %}
19 <span class="inline-flex rounded-full bg-yellow-900/50 px-2 text-xs font-semibold leading-5 text-yellow-300">Draft</span>
20 {% endif %}
21 </td>
22 <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">{{ page.updated_at|date:"N j, Y" }}</td>
23 <td class="px-6 py-4 whitespace-nowrap text-right text-sm">
24 {% if perms.pages.change_page %}
25 <a href="{% url 'pages:update' slug=page.slug %}" class="text-brand-light hover:text-brand">Edit</a>
26 {% endif %}
27 </td>
28 </tr>
29 {% empty %}
30 <tr>
31 <td colspan="4" class="px-6 py-8 text-center text-sm text-gray-400">No pages found.</td>
32 </tr>
33 {% endfor %}
34 </tbody>
35 </table>
36 </div>
37 </div>
--- a/templates/projects/partials/project_table.html
+++ b/templates/projects/partials/project_table.html
@@ -0,0 +1,10 @@
1
+<div id="project-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2
+ <table class="min-w-full divide-y divide-gray-700">
3
+ <thead class="bg-gray-900">
4
+ <tr>
5
+ <div id="project-table">
6
+ <div class="overflow-x-auto rounded-lg border border-gray-700 bg-grayext-gray-400">Visibility</th>
7
+ <th class="px-6 py-3 text-left textext-gray-400">Teams</th>
8
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase t bg-gray-800">
9
+ {% for project in projects %}
10
+ <tr class="hover:bg-gray-700/50
--- a/templates/projects/partials/project_table.html
+++ b/templates/projects/partials/project_table.html
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/partials/project_table.html
+++ b/templates/projects/partials/project_table.html
@@ -0,0 +1,10 @@
1 <div id="project-tablhidden<div class="overflow-x-auto rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
2 <table class="min-w-full divide-y divide-gray-700">
3 <thead class="bg-gray-900">
4 <tr>
5 <div id="project-table">
6 <div class="overflow-x-auto rounded-lg border border-gray-700 bg-grayext-gray-400">Visibility</th>
7 <th class="px-6 py-3 text-left textext-gray-400">Teams</th>
8 <th class="px-6 py-3 text-left text-xs font-medium uppercase t bg-gray-800">
9 {% for project in projects %}
10 <tr class="hover:bg-gray-700/50
--- a/templates/projects/partials/project_team_table.html
+++ b/templates/projects/partials/project_team_table.html
@@ -0,0 +1 @@
1
+<div id="project-team-tablhidden
--- a/templates/projects/partials/project_team_table.html
+++ b/templates/projects/partials/project_team_table.html
@@ -0,0 +1 @@
 
--- a/templates/projects/partials/project_team_table.html
+++ b/templates/projects/partials/project_team_table.html
@@ -0,0 +1 @@
1 <div id="project-team-tablhidden
--- a/templates/projects/project_confirm_delete.html
+++ b/templates/projects/project_confirm_delete.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Delete {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-lg">
10
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11
+ <h2 class="text-lg font-semibold text-gray-100">Delete Project</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to delete <strong class="text-gray-100">{{ project.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'projects:detail' slug=project.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Delete
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/projects/project_confirm_delete.html
+++ b/templates/projects/project_confirm_delete.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_confirm_delete.html
+++ b/templates/projects/project_confirm_delete.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Delete {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-lg">
10 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11 <h2 class="text-lg font-semibold text-gray-100">Delete Project</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to delete <strong class="text-gray-100">{{ project.name }}</strong>? This action uses soft delete — the record will be marked as deleted but can be recovered.
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'projects:detail' slug=project.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Delete
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/projects/project_detail.html
+++ b/templates/projects/project_detail.html
@@ -0,0 +1,8 @@
1
+/path/to/roject.slug }}/fossiil/xfer {{ project.slug }}.fossil</code>
2
+ <butto/path/to/roject.slug }}/fossil/xfe'); copied = true; setTimeout(() => copied = false, 1500)"
3
+ class="flex-shrink-0 rounded px-2 py-2 text-gray-500 hover:text-brand-light hover:bg-gray-700">
4
+ <svg x-sx="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c.125-1.12g-gray-800 border bordeitems-center g }} <span class="text-xs text-gray-500">{{ c.count }} commits</span>
5
+ </a>>
6
+ <dt class="text-gray-500">Visibility</dt>
7
+ <dd>
8
+ {% if proje }}<span
--- a/templates/projects/project_detail.html
+++ b/templates/projects/project_detail.html
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
--- a/templates/projects/project_detail.html
+++ b/templates/projects/project_detail.html
@@ -0,0 +1,8 @@
1 /path/to/roject.slug }}/fossiil/xfer {{ project.slug }}.fossil</code>
2 <butto/path/to/roject.slug }}/fossil/xfe'); copied = true; setTimeout(() => copied = false, 1500)"
3 class="flex-shrink-0 rounded px-2 py-2 text-gray-500 hover:text-brand-light hover:bg-gray-700">
4 <svg x-sx="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c.125-1.12g-gray-800 border bordeitems-center g }} <span class="text-xs text-gray-500">{{ c.count }} commits</span>
5 </a>>
6 <dt class="text-gray-500">Visibility</dt>
7 <dd>
8 {% if proje }}<span
--- a/templates/projects/project_form.html
+++ b/templates/projects/project_form.html
@@ -0,0 +1,33 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ title }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Projects</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
+
12
+ <form method="post" class="space-y-6 roundeg bg-gray-800 p-6 shadow-sm border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {ow #}
16
+ {% else %}
17
+ <div>
18
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
19
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
20
+ </label>
21
+ <div class="mt-1">{{ field }}</div>
22
+ {% if field.errors %}
23
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
24
+ {% endfor source: '{{ form.repo_source.value|default:"empty" }}' }">
25
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Repository Source</h3>
26
+ <div class="space-y-3">
27
+ <label class="flex items-center ">
28
+" x-modee="repo_source" value="empty" x-model="source"
29
+ class="text-brand focus:ring-brand">
30
+ <div>
31
+ <span class="text-sm text-gray- </label>
32
+ <label class="flex items-center gap-3 cursor-pointer">
33
+ <input
--- a/templates/projects/project_form.html
+++ b/templates/projects/project_form.html
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_form.html
+++ b/templates/projects/project_form.html
@@ -0,0 +1,33 @@
1 {% extends "base.html" %}
2 {% block title %}{{ title }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:list' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Projects</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">{{ title }}</h1>
11
12 <form method="post" class="space-y-6 roundeg bg-gray-800 p-6 shadow-sm border border-gray-700">
13 {% csrf_token %}
14
15 {ow #}
16 {% else %}
17 <div>
18 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
19 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
20 </label>
21 <div class="mt-1">{{ field }}</div>
22 {% if field.errors %}
23 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
24 {% endfor source: '{{ form.repo_source.value|default:"empty" }}' }">
25 <h3 class="text-sm font-medium text-gray-300 mb-3">Repository Source</h3>
26 <div class="space-y-3">
27 <label class="flex items-center ">
28 " x-modee="repo_source" value="empty" x-model="source"
29 class="text-brand focus:ring-brand">
30 <div>
31 <span class="text-sm text-gray- </label>
32 <label class="flex items-center gap-3 cursor-pointer">
33 <input
--- a/templates/projects/project_list.html
+++ b/templates/projects/project_list.html
@@ -0,0 +1 @@
1
+{% extends "base.html"class="w-full max-w-md /endblock %}
--- a/templates/projects/project_list.html
+++ b/templates/projects/project_list.html
@@ -0,0 +1 @@
 
--- a/templates/projects/project_list.html
+++ b/templates/projects/project_list.html
@@ -0,0 +1 @@
1 {% extends "base.html"class="w-full max-w-md /endblock %}
--- a/templates/projects/project_team_add.html
+++ b/templates/projects/project_team_add.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Add Team to {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Team to {{ project.name }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'projects:detail' slug=project.slug %}"
29
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Add Team
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/projects/project_team_add.html
+++ b/templates/projects/project_team_add.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_team_add.html
+++ b/templates/projects/project_team_add.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Add Team to {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Add Team to {{ project.name }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'projects:detail' slug=project.slug %}"
29 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Add Team
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}
--- a/templates/projects/project_team_confirm_remove.html
+++ b/templates/projects/project_team_confirm_remove.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Remove Team — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-lg">
10
+ <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11
+ <h2 class="text-lg font-semibold text-gray-100">Remove Team</h2>
12
+ <p class="mt-2 text-sm text-gray-400">
13
+ Are you sure you want to remove <strong class="text-gray-100">{{ team.name }}</strong> from <strong class="text-gray-100">{{ project.name }}</strong>?
14
+ </p>
15
+ <form method="post" class="mt-6 flex justify-end gap-3">
16
+ {% csrf_token %}
17
+ <a href="{% url 'projects:detail' slug=project.slug %}"
18
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19
+ Cancel
20
+ </a>
21
+ <button type="submit"
22
+ class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23
+ Remove
24
+ </button>
25
+ </form>
26
+ </div>
27
+</div>
28
+{% endblock %}
--- a/templates/projects/project_team_confirm_remove.html
+++ b/templates/projects/project_team_confirm_remove.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_team_confirm_remove.html
+++ b/templates/projects/project_team_confirm_remove.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Remove Team — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-lg">
10 <div class="rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
11 <h2 class="text-lg font-semibold text-gray-100">Remove Team</h2>
12 <p class="mt-2 text-sm text-gray-400">
13 Are you sure you want to remove <strong class="text-gray-100">{{ team.name }}</strong> from <strong class="text-gray-100">{{ project.name }}</strong>?
14 </p>
15 <form method="post" class="mt-6 flex justify-end gap-3">
16 {% csrf_token %}
17 <a href="{% url 'projects:detail' slug=project.slug %}"
18 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
19 Cancel
20 </a>
21 <button type="submit"
22 class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500">
23 Remove
24 </button>
25 </form>
26 </div>
27 </div>
28 {% endblock %}
--- a/templates/projects/project_team_edit.html
+++ b/templates/projects/project_team_edit.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}Edit Team Role — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<div class="mb-6">
6
+ <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7
+</div>
8
+
9
+<div class="mx-auto max-w-2xl">
10
+ <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit {{ team.name }} Role on {{ project.name }}</h1>
11
+
12
+ <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13
+ {% csrf_token %}
14
+
15
+ {% for field in form %}
16
+ <div>
17
+ <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18
+ {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19
+ </label>
20
+ <div class="mt-1">{{ field }}</div>
21
+ {% if field.errors %}
22
+ <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23
+ {% endif %}
24
+ </div>
25
+ {% endfor %}
26
+
27
+ <div class="flex justify-end gap-3 pt-4">
28
+ <a href="{% url 'projects:detail' slug=project.slug %}"
29
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30
+ Cancel
31
+ </a>
32
+ <button type="submit"
33
+ class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34
+ Update Role
35
+ </button>
36
+ </div>
37
+ </form>
38
+</div>
39
+{% endblock %}
--- a/templates/projects/project_team_edit.html
+++ b/templates/projects/project_team_edit.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/project_team_edit.html
+++ b/templates/projects/project_team_edit.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}Edit Team Role — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-6">
6 <a href="{% url 'projects:detail' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to {{ project.name }}</a>
7 </div>
8
9 <div class="mx-auto max-w-2xl">
10 <h1 class="text-2xl font-bold text-gray-100 mb-6">Edit {{ team.name }} Role on {{ project.name }}</h1>
11
12 <form method="post" class="space-y-6 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
13 {% csrf_token %}
14
15 {% for field in form %}
16 <div>
17 <label for="{{ field.id_for_label }}" class="block text-sm font-medium text-gray-300">
18 {{ field.label }}{% if field.field.required %} <span class="text-red-400">*</span>{% endif %}
19 </label>
20 <div class="mt-1">{{ field }}</div>
21 {% if field.errors %}
22 <p class="mt-1 text-sm text-red-400">{{ field.errors.0 }}</p>
23 {% endif %}
24 </div>
25 {% endfor %}
26
27 <div class="flex justify-end gap-3 pt-4">
28 <a href="{% url 'projects:detail' slug=project.slug %}"
29 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
30 Cancel
31 </a>
32 <button type="submit"
33 class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
34 Update Role
35 </button>
36 </div>
37 </form>
38 </div>
39 {% endblock %}

No diff available

--- a/testdata/apps.py
+++ b/testdata/apps.py
@@ -0,0 +1,6 @@
1
+from django.apps import AppConfig
2
+
3
+
4
+class TestdataConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "testdata"
--- a/testdata/apps.py
+++ b/testdata/apps.py
@@ -0,0 +1,6 @@
 
 
 
 
 
 
--- a/testdata/apps.py
+++ b/testdata/apps.py
@@ -0,0 +1,6 @@
1 from django.apps import AppConfig
2
3
4 class TestdataConfig(AppConfig):
5 default_auto_field = "django.db.models.BigAutoField"
6 name = "testdata"

No diff available

--- a/testdata/management/commands/seed.py
+++ b/testdata/management/commands/seed.py
@@ -0,0 +1,138 @@
1
+import logging
2
+
3
+from django.contrib.auth.models import Group, Permission, User
4
+from django.core.management.base import BaseCommand
5
+
6
+from items.models import Item
7
+from organization.models import Organization, OrganizationMember, Team
8
+from pages.models import Page
9
+from projects.models import Project, ProjectTeam
10
+
11
+logger = logging.getLogger(__name__)
12
+
13
+
14
+class Command(BaseCommand):
15
+ help = "Seed the database with initial data for development."
16
+
17
+ def add_arguments(self, parser):
18
+ parser.add_argument("--flush", action="store_true", help="Flush non-system tables before seeding.")
19
+
20
+ def handle(self, *args, **options):
21
+ if options["flush"]:
22
+ self.stdout.write("Flushing data...")
23
+ Page.all_objects.all().delete()
24
+ ProjectTeam.all_objects.all().delete()
25
+ Project.all_objects.Item.all_objects.Team.all_objects.all().delete()
26
+ OrganizationMember.all_objects.all().delete()
27
+ Organization.all_objects.all().delete()
28
+
29
+ # Groups and permissions
30
+ admin_group, _ = Group.objects.get_or_create(name="Administrators")
31
+ viewer_group, _ = Group.objects.get_or_create(name="Viewers")
32
+
33
+ # Admin grouitems, org, and projects
34
+oject=dofor app_label in ["items", :
35
+ perms = Permission.objects.filter(content_type__app_label=app_label)
36
+ admin_group.permissions.add(*perms)
37
+
38
+ # Viewer groupitems, org, and projects
39
+ rojects, and pages
40
+ view_perms = Permission.objects.filter(
41
+ conitems", ntent_type__app_label__in=["organization", "projects", "pages"],
42
+ codename__startswith="view_",
43
+ )
44
+ viewer_group.permissions.set(view_perms)
45
+
46
+ # Superuser
47
+ admin_user, created = User.objects.get_or_create(
48
+ username="admin",
49
+ defaults={"email": "[email protected]", "is_staff": True, "is_superuser": True},
50
+ )
51
+ if created:
52
+ admin_user.set_password("admin")
53
+ admin_user.save()
54
+ self.stdout.write(self.style.SUCCESS("Created superuser: admin / admin"))
55
+
56
+ # Regular user
57
+ viewer_user, created = User.objects.get_or_create(
58
+ username="viewer",
59
+ defaults={"email": "[email protected]", "is_staff": False, "is_superuser": False},
60
+ )
61
+ if created:
62
+ viewer_user.set_password("viewer")
63
+ viewer_user.save()
64
+ viewer_user.groups.add(viewer_group)
65
+ self.stdout.write(self.style.SUCCESS("Created viewer user: viewer / viewer"))
66
+
67
+ # Organization
68
+ org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"})
69
+ OrganizationMember.objects.get_or_create(member=admin_user, organization=org)
70
+ OrganizationMember.objects.get_or_create(member=viewer_user, organization=org)
71
+
72
+ # Teams
73
+ core_devs, _ = Team.objects.get_or_create(name="Core Devs", defaults={"organization": org, "description": "Core development team"})
74
+ core_devs.members.add(admin_user)
75
+
76
+ contributors, _ = Team.objects.get_or_create(
77
+ name="Contributors", defaults={"organization": org, "description": "Community contributors"}
78
+ )
79
+ contributors.members.add(viewer_user)
80
+
81
+ reviewers, _ = Team.objects.get_or_create(name="Reviewers", defaults={"organization": org, "description": "Code reitems
82
+ item_or_create(
83
+ username=Widget Alpha", "price admin_user.set_password("admin")
84
+ admin_user.save()
85
+ self.stA versatile alpha widget.))
86
+
87
+ # Regular userWidget Beta", "price admin_user.set_password("admin")
88
+ admin_user.save()
89
+ self.stdout.write(self.style.SUCCESS("Created superuser: admin / admin"Enhanced beta ))
90
+
91
+ # Regular user
92
+ viewer_user, created = User.objects.get_or_create(
93
+ username="viewer",
94
+ defaults={"email": "[email protected]", "is_staff": False, "is_superuser": False},
95
+ )
96
+ if created:
97
+ viewer_user.set_password("viewer")
98
+ viewer_user.save()
99
+ viewer_user.groups.add(viewer_group)
100
+ self.stdout.write(self.style.SUCCESS("Created viewer user: viewer / viewer"))
101
+
102
+ # Organization
103
+ org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"})
104
+ OrganizationMember.objects.get_or_create(member=admin_user, organization=org)
105
+ OrganizationMember.objects.get_or_create(member=viewer_user, organization=org)
106
+
107
+ # Teams
108
+ core_devs, _ = Team.objects.get_or_create(name="Core Devs", defaults={"organization": org, "description": "Core development team"})
109
+ core_devs.members.add(admin_user)
110
+
111
+ contributors, _ = Team.objects.get_or_create(
112
+ name="Contributors", defaults={"organization": org, "description": "Community contributors"}
113
+ )
114
+ contributors.members.add(viewer_user)
115
+
116
+ reviewers, _ = Team.objects.get_or_create(name="Reviewers", defaults={"organization": org, "description": "Code review team"})
117
+ reviewers.members.add(admin_user, viewer_user)
118
+
119
+ # Projects
120
+ projects_data = [
121
+ {"name": "Frontend App", "description": "User-facing web application", "visibility": "internal"},
122
+ {"name": "Backend API", "description": "Core API service", "visibility": "private"},
123
+ {"name": "Documentation", "description": "Project documentation and guides", "visibility": "public"},
124
+ {"name": "Infrastructure", "description": "Deployment and infrastructure tooling", "visibility": "private"},
125
+ ]
126
+ for pdata in projects_data:
127
+ project, _ = Project.objects.get_or_create(
128
+ name=pdata["name"],
129
+ defaults={**pdata, "organization": org, "created_by": admin_user},
130
+ )
131
+
132
+ # Team-project assignments
133
+ frontend = Project.objects.filter(name="Frontend App").first()
134
+ backend = Project.objects.filter(name="Backend API").first()
135
+ docs = Project.objects.filter(name="Documentation").first()
136
+
137
+ if frontend:
138
+ ProjectTeam.objects.get_or_create(project=frontend, team=core_devs, defaults={"role":
--- a/testdata/management/commands/seed.py
+++ b/testdata/management/commands/seed.py
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/testdata/management/commands/seed.py
+++ b/testdata/management/commands/seed.py
@@ -0,0 +1,138 @@
1 import logging
2
3 from django.contrib.auth.models import Group, Permission, User
4 from django.core.management.base import BaseCommand
5
6 from items.models import Item
7 from organization.models import Organization, OrganizationMember, Team
8 from pages.models import Page
9 from projects.models import Project, ProjectTeam
10
11 logger = logging.getLogger(__name__)
12
13
14 class Command(BaseCommand):
15 help = "Seed the database with initial data for development."
16
17 def add_arguments(self, parser):
18 parser.add_argument("--flush", action="store_true", help="Flush non-system tables before seeding.")
19
20 def handle(self, *args, **options):
21 if options["flush"]:
22 self.stdout.write("Flushing data...")
23 Page.all_objects.all().delete()
24 ProjectTeam.all_objects.all().delete()
25 Project.all_objects.Item.all_objects.Team.all_objects.all().delete()
26 OrganizationMember.all_objects.all().delete()
27 Organization.all_objects.all().delete()
28
29 # Groups and permissions
30 admin_group, _ = Group.objects.get_or_create(name="Administrators")
31 viewer_group, _ = Group.objects.get_or_create(name="Viewers")
32
33 # Admin grouitems, org, and projects
34 oject=dofor app_label in ["items", :
35 perms = Permission.objects.filter(content_type__app_label=app_label)
36 admin_group.permissions.add(*perms)
37
38 # Viewer groupitems, org, and projects
39 rojects, and pages
40 view_perms = Permission.objects.filter(
41 conitems", ntent_type__app_label__in=["organization", "projects", "pages"],
42 codename__startswith="view_",
43 )
44 viewer_group.permissions.set(view_perms)
45
46 # Superuser
47 admin_user, created = User.objects.get_or_create(
48 username="admin",
49 defaults={"email": "[email protected]", "is_staff": True, "is_superuser": True},
50 )
51 if created:
52 admin_user.set_password("admin")
53 admin_user.save()
54 self.stdout.write(self.style.SUCCESS("Created superuser: admin / admin"))
55
56 # Regular user
57 viewer_user, created = User.objects.get_or_create(
58 username="viewer",
59 defaults={"email": "[email protected]", "is_staff": False, "is_superuser": False},
60 )
61 if created:
62 viewer_user.set_password("viewer")
63 viewer_user.save()
64 viewer_user.groups.add(viewer_group)
65 self.stdout.write(self.style.SUCCESS("Created viewer user: viewer / viewer"))
66
67 # Organization
68 org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"})
69 OrganizationMember.objects.get_or_create(member=admin_user, organization=org)
70 OrganizationMember.objects.get_or_create(member=viewer_user, organization=org)
71
72 # Teams
73 core_devs, _ = Team.objects.get_or_create(name="Core Devs", defaults={"organization": org, "description": "Core development team"})
74 core_devs.members.add(admin_user)
75
76 contributors, _ = Team.objects.get_or_create(
77 name="Contributors", defaults={"organization": org, "description": "Community contributors"}
78 )
79 contributors.members.add(viewer_user)
80
81 reviewers, _ = Team.objects.get_or_create(name="Reviewers", defaults={"organization": org, "description": "Code reitems
82 item_or_create(
83 username=Widget Alpha", "price admin_user.set_password("admin")
84 admin_user.save()
85 self.stA versatile alpha widget.))
86
87 # Regular userWidget Beta", "price admin_user.set_password("admin")
88 admin_user.save()
89 self.stdout.write(self.style.SUCCESS("Created superuser: admin / admin"Enhanced beta ))
90
91 # Regular user
92 viewer_user, created = User.objects.get_or_create(
93 username="viewer",
94 defaults={"email": "[email protected]", "is_staff": False, "is_superuser": False},
95 )
96 if created:
97 viewer_user.set_password("viewer")
98 viewer_user.save()
99 viewer_user.groups.add(viewer_group)
100 self.stdout.write(self.style.SUCCESS("Created viewer user: viewer / viewer"))
101
102 # Organization
103 org, _ = Organization.objects.get_or_create(name="Fossilrepo HQ", defaults={"description": "Default organization"})
104 OrganizationMember.objects.get_or_create(member=admin_user, organization=org)
105 OrganizationMember.objects.get_or_create(member=viewer_user, organization=org)
106
107 # Teams
108 core_devs, _ = Team.objects.get_or_create(name="Core Devs", defaults={"organization": org, "description": "Core development team"})
109 core_devs.members.add(admin_user)
110
111 contributors, _ = Team.objects.get_or_create(
112 name="Contributors", defaults={"organization": org, "description": "Community contributors"}
113 )
114 contributors.members.add(viewer_user)
115
116 reviewers, _ = Team.objects.get_or_create(name="Reviewers", defaults={"organization": org, "description": "Code review team"})
117 reviewers.members.add(admin_user, viewer_user)
118
119 # Projects
120 projects_data = [
121 {"name": "Frontend App", "description": "User-facing web application", "visibility": "internal"},
122 {"name": "Backend API", "description": "Core API service", "visibility": "private"},
123 {"name": "Documentation", "description": "Project documentation and guides", "visibility": "public"},
124 {"name": "Infrastructure", "description": "Deployment and infrastructure tooling", "visibility": "private"},
125 ]
126 for pdata in projects_data:
127 project, _ = Project.objects.get_or_create(
128 name=pdata["name"],
129 defaults={**pdata, "organization": org, "created_by": admin_user},
130 )
131
132 # Team-project assignments
133 frontend = Project.objects.filter(name="Frontend App").first()
134 backend = Project.objects.filter(name="Backend API").first()
135 docs = Project.objects.filter(name="Documentation").first()
136
137 if frontend:
138 ProjectTeam.objects.get_or_create(project=frontend, team=core_devs, defaults={"role":

No diff available

No diff available

+1341
--- a/uv.lock
+++ b/uv.lock
@@ -0,0 +1,1341 @@
1
+version = 1
2
+revision = 3
3
+requires-python = ">=3.12"
4
+
5
+[[package]]
6
+name = "amqp"
7
+version = "5.3.1"
8
+source = { registry = "https://pypi.org/simple" }
9
+dependencies = [
10
+ { name = "vine" },
11
+]
12
+sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
13
+wheels = [
14
+ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
15
+]
16
+
17
+[[package]]
18
+name = "asgiref"
19
+version = "3.11.1"
20
+source = { registry = "https://pypi.org/simple" }
21
+sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
22
+wheels = [
23
+ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
24
+]
25
+
26
+[[package]]
27
+name = "billiard"
28
+version = "4.2.4"
29
+source = { registry = "https://pypi.org/simple" }
30
+sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
31
+wheels = [
32
+ { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
33
+]
34
+
35
+[[package]]
36
+name = "boolean-py"
37
+version = "5.0"
38
+source = { registry = "https://pypi.org/simple" }
39
+sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
40
+wheels = [
41
+ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
42
+]
43
+
44
+[[package]]
45
+name = "boto3"
46
+version = "1.42.76"
47
+source = { registry = "https://pypi.org/simple" }
48
+dependencies = [
49
+ { name = "botocore" },
50
+ { name = "jmespath" },
51
+ { name = "s3transfer" },
52
+]
53
+sdist = { url = "https://files.pythonhosted.org/packages/f1/13/33c8b8704d677fcaf5555ba8c6cc39468fc7b9a0c6b6c496e008cd5557fc/boto3-1.42.76.tar.gz", hash = "sha256:aa2b1973eee8973a9475d24bb579b1dee7176595338d4e4f7880b5c6189b8814", size = 112789, upload-time = "2026-03-25T19:33:25.985Z" }
54
+wheels = [
55
+ { url = "https://files.pythonhosted.org/packages/f0/dc/21b3dfb135125eb7e3a46b9aab0aede847726f239fc8f39474742a87ebb0/boto3-1.42.76-py3-none-any.whl", hash = "sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e", size = 140557, upload-time = "2026-03-25T19:33:23.289Z" },
56
+]
57
+
58
+[[package]]
59
+name = "botocore"
60
+version = "1.42.76"
61
+source = { registry = "https://pypi.org/simple" }
62
+dependencies = [
63
+ { name = "jmespath" },
64
+ { name = "python-dateutil" },
65
+ { name = "urllib3" },
66
+]
67
+sdist = { url = "https://files.pythonhosted.org/packages/70/62/a982acb81c5e0312f90f841b790abad65622c08aad356eed7008ea3d475b/botocore-1.42.76.tar.gz", hash = "sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874", size = 15021811, upload-time = "2026-03-25T19:33:12.171Z" }
68
+wheels = [
69
+ { url = "https://files.pythonhosted.org/packages/f5/63/7429d68876b7718ab5c4b8a44414de7907f5ba6bb27ccfad384df14fb277/botocore-1.42.76-py3-none-any.whl", hash = "sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607", size = 14697736, upload-time = "2026-03-25T19:33:07.573Z" },
70
+]
71
+
72
+[[package]]
73
+name = "cachecontrol"
74
+version = "0.14.4"
75
+source = { registry = "https://pypi.org/simple" }
76
+dependencies = [
77
+ { name = "msgpack" },
78
+ { name = "requests" },
79
+]
80
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
81
+wheels = [
82
+ { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
83
+]
84
+
85
+[package.optional-dependencies]
86
+filecache = [
87
+ { name = "filelock" },
88
+]
89
+
90
+[[package]]
91
+name = "version = 1
92
+revisiversion = 1
93
+revision = 3
94
+requires-python = ">=3.12"
95
+
96
+[[package]]
97
+name = "amqp"
98
+version = "5.3.1"
99
+source = { registry = "https://pypi.org/simple" }
100
+dependencies = [
101
+ { name = "vine" },
102
+]
103
+sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
104
+wheels = [
105
+ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
106
+]
107
+
108
+[[package]]
109
+name = "asgiref"
110
+version = "3.11.1"
111
+source = { registry = "https://pypi.org/simple" }
112
+sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
113
+wheels = [
114
+ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
115
+]
116
+
117
+[[package]]
118
+name = "billiard"
119
+version = "4.2.4"
120
+source = { registry = "https://pypi.org/simple" }
121
+sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
122
+wheels = [
123
+ { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
124
+]
125
+
126
+[[package]]
127
+name = "boolean-py"
128
+version = "5.0"
129
+source = { registry = "https://pypi.org/simple" }
130
+sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
131
+wheels = [
132
+ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
133
+]
134
+
135
+[[package]]
136
+name = "boto3"
137
+version = "1.42.76"
138
+source = { registry = "https://pypi.org/simple" }
139
+dependencies = [
140
+ { name = "botocore" },
141
+ { name = "jmespath" },
142
+ { name = "s3transfer" },
143
+]
144
+sdist = { url = "https://files.pythonhosted.org/packages/f1/13/33c8b8704d677fcaf5555ba8c6cc39468fc7b9a0c6b6c496e008cd5557fc/boto3-1.42.76.tar.gz", hash = "sha256:aa2b1973eee8973a9475d24bb579b1dee7176595338d4e4f7880b5c6189b8814", size = 112789, upload-time = "2026-03-25T19:33:25.985Z" }
145
+wheels = [
146
+ { url = "https://files.pythonhosted.org/packages/f0/dc/21b3dfb135125eb7e3a46b9aab0aede847726f239fc8f39474742a87ebb0/boto3-1.42.76-py3-none-any.whl", hash = "sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e", size = 140557, upload-time = "2026-03-25T19:33:23.289Z" },
147
+]
148
+
149
+[[package]]
150
+name = "botocore"
151
+version = "1.42.76"
152
+source = { registry = "https://pypi.org/simple" }
153
+dependencies = [
154
+ { name = "jmespath" },
155
+ { name = "python-dateutil" },
156
+ { name = "urllib3" },
157
+]
158
+sdist = { url = "https://files.pythonhosted.org/packages/70/62/a982acb81c5e0312f90f841b790abad65622c08aad356eed7008ea3d475b/botocore-1.42.76.tar.gz", hash = "sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874", size = 15021811, upload-time = "2026-03-25T19:33:12.171Z" }
159
+wheels = [
160
+ { url = "https://files.pythonhosted.org/packages/f5/63/7429d68876b7718ab5c4b8a44414de7907f5ba6bb27ccfad384df14fb277/botocore-1.42.76-py3-none-any.whl", hash = "sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607", size = 14697736, upload-time = "2026-03-25T19:33:07.573Z" },
161
+]
162
+
163
+[[package]]
164
+name = "cachecontrol"
165
+version = "0.14.4"
166
+source = { registry = "https://pypi.org/simple" }
167
+dependencies = [
168
+ { name = "msgpack" },
169
+ { name = "requests" },
170
+]
171
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
172
+wheels = [
173
+ { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
174
+]
175
+
176
+[package.optional-dependencies]
177
+filecache = [
178
+ { name = "filelock" },
179
+]
180
+
181
+[[package]]
182
+name = "celery"
183
+version = "5.6.3"
184
+source = { registry = "https://pypi.org/simple" }
185
+dependencies = [
186
+ { name = "billiard" },
187
+ { name = "click" },
188
+ { name = "click-didyoumean" },
189
+ { name = "click-plugins" },
190
+ { name = "click-repl" },
191
+ { name = "kombu" },
192
+ { name = "python-dateutil" },
193
+ { name = "tzlocal" },
194
+ { name = "vine" },
195
+]
196
+sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" }
197
+wheels = [
198
+ { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" },
199
+]
200
+
201
+[package.optional-dependencies]
202
+redis = [
203
+ { name = "kombu", extra = ["redis"] },
204
+]
205
+
206
+[[package]]
207
+name = "certifi"
208
+version = "2026.2.25"
209
+source = { registry = "https://pypi.org/simple" }
210
+sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
211
+wheels = [
212
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
213
+]
214
+
215
+[[package]]
216
+name = "cffi"
217
+version = "2.0.0"
218
+source = { registry = "https://pypi.org/simple" }
219
+dependencies = [
220
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
221
+]
222
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
223
+wheels = [
224
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
225
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
226
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
227
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
228
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
229
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
230
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
231
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
232
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
233
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
234
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
235
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
236
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
237
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
238
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
239
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
240
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
241
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
242
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
243
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
244
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
245
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
246
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
247
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
248
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
249
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
250
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
251
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
252
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
253
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
254
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
255
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
256
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
257
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
258
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
259
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
260
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
261
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
262
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
263
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
264
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
265
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
266
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
267
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
268
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
269
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
270
+]
271
+
272
+[[package]]
273
+name = "charset-normalizer"
274
+version = "3.4.6"
275
+source = { registry = "https://pypi.org/simple" }
276
+sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
277
+wheels = [
278
+ { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
279
+ { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
280
+ { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
281
+ { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
282
+ { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
283
+ { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
284
+ { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
285
+ { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
286
+ { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
287
+ { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
288
+ { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
289
+ { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
290
+ { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
291
+ { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
292
+ { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
293
+ { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
294
+ { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
295
+ { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
296
+ { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
297
+ { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
298
+ { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
299
+ { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
300
+ { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
301
+ { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
302
+ { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
303
+ { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
304
+ { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
305
+ { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
306
+ { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
307
+ { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
308
+ { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
309
+ { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
310
+ { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
311
+ { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
312
+ { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
313
+ { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
314
+ { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
315
+ { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
316
+ { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
317
+ { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
318
+ { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
319
+ { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
320
+ { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
321
+ { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
322
+ { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
323
+ { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
324
+ { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
325
+ { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
326
+ { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
327
+ { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
328
+ { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
329
+ { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
330
+ { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
331
+ { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
332
+ { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
333
+ { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
334
+ { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
335
+ { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
336
+ { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
337
+ { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
338
+ { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
339
+ { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
340
+ { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
341
+ { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
342
+ { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
343
+]
344
+
345
+[[package]]
346
+name = "click"
347
+version = "8.3.1"
348
+source = { registry = "https://pypi.org/simple" }
349
+dependencies = [
350
+ { name = "colorama", marker = "sys_platform == 'win32'" },
351
+]
352
+sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
353
+wheels = [
354
+ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
355
+]
356
+
357
+[[package]]
358
+name = "click-didyoumean"
359
+version = "0.3.1"
360
+source = { registry = "https://pypi.org/simple" }
361
+dependencies = [
362
+ { name = "click" },
363
+]
364
+sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" }
365
+wheels = [
366
+ { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" },
367
+]
368
+
369
+[[package]]
370
+name = "click-plugins"
371
+version = "1.1.1.2"
372
+source = { registry = "https://pypi.org/simple" }
373
+dependencies = [
374
+ { name = "click" },
375
+]
376
+sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" }
377
+wheels = [
378
+ { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" },
379
+]
380
+
381
+[[package]]
382
+name = "click-repl"
383
+version = "0.3.0"
384
+source = { registry = "https://pypi.org/simple" }
385
+dependencies = [
386
+ { name = "click" },
387
+ { name = "prompt-toolkit" },
388
+]
389
+sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" }
390
+wheels = [
391
+ { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" },
392
+]
393
+
394
+[[package]]
395
+name = "colorama"
396
+version = "0.4.6"
397
+source = { registry = "https://pypi.org/simple" }
398
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
399
+wheels = [
400
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
401
+]
402
+
403
+[[package]]
404
+name = "coverage"
405
+version = "7.13.5"
406
+source = { registry = "https://pypi.org/simple" }
407
+sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
408
+wheels = [
409
+ { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
410
+ { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
411
+ { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
412
+ { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
413
+ { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964dyclonedx-python-lib"
414
+version = "11.7.0"
415
+source = { registry = "https://pypi.org/simple" }
416
+dependencies = [
417
+ { name = "license-expression" },
418
+ { name = "packageurl-python" },
419
+ { name = "py-serializable" },
420
+ { name = "sortedcontainers" },
421
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
422
+]
423
+sdist = { url = "https://files.pythonhosted.org/packages/21/0d/64f02d3fd9c116d6f50a540d04d1e4f2e3c487f5062d2db53733ddb25917/cyclonedx_python_lib-11.7.0.tar.gz", hash = "sha256:fb1bc3dedfa31208444dbd743007f478ab6984010a184e5bd466bffd969e936e", size = 1411174, upload-time = "2026-03-17T15:19:16.606Z" }
424
+wheels = [
425
+ { url = "https://files.pythonhosted.org/packages/30/09/fe0e3bc32bd33707c519b102fc064ad2a2ce5a1b53e2be38b86936b476b1/cyclonedx_python_lib-11.7.0-py3-none-any.whl", hash = "sha256:02fa4f15ddbba21ac9093039f8137c0d1813af7fe88b760c5dcd3311a8da2178", size = 513041, upload-time = "2026-03-17T15:19:14.369Z" },
426
+]
427
+
428
+[[package]]
429
+name = "defusedxml"
430
+version = "0.7.1"
431
+source = { registry = "https://pypi.org/simple" }
432
+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
433
+wheels = [
434
+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
435
+]
436
+
437
+[[package]]
438
+name = "diff-match-patch"
439
+version = "20241021"
440
+source = { registry = "https://pypi.org/simple" }
441
+sdist = { url = "https://files.pythonhosted.org/packages/0e/ad/32e1777dd57d8e85fa31e3a243af66c538245b8d64b7265bec9a61f2ca33/diff_match_patch-20241021.tar.gz", hash = "sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073", size = 39962, upload-time = "2024-10-21T19:41:21.094Z" }
442
+wheels = [
443
+ { url = "https://files.pythonhosted.org/packages/f7/bb/2aa9b46a01197398b901e458974c20ed107935c26e44e37ad5b0e5511e44/diff_match_patch-20241021-py3-none-any.whl", hash = "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", size = 43252, upload-time = "2024-10-21T19:41:19.914Z" },
444
+]
445
+
446
+[[package]]
447
+name = "django"
448
+version = "5.2.12"
449
+source = { registry = "https://pypi.org/simple" }
450
+dependencies = [
451
+ { name = "asgiref" },
452
+ { name = "sqlparse" },
453
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
454
+]
455
+sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" }
456
+wheels = [
457
+ { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" },
458
+]
459
+
460
+[[package]]
461
+name = "django-celery-beat"
462
+version = "2.9.0"
463
+source = { registry = "https://pypi.org/simple" }
464
+dependencies = [
465
+ { name = "celery" },
466
+ { name = "cron-descriptor" },
467
+ { name = "django" },
468
+ { name = "django-timezone-field" },
469
+ { name = "python-crontab" },
470
+ { name = "tzdata" },
471
+]
472
+sdist = { url = "https://files.pythonhosted.org/packages/05/45/fc97bc1d9af8e7dc07f1e37044d9551a30e6793249864cef802341e2e3a8/django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c", size = 177667, upload-time = "2026-02-28T16:45:34.749Z" }
473
+wheels = [
474
+ { url = "https://files.pythonhosted.org/packages/71/ae/9befa7ae37f5e5c41be636a254fcf47ff30dd5c88bd115070e252f6b9162/django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157", size = 105013, upload-time = "2026-02-28T16:45:32.822Z" },
475
+]
476
+
477
+[[package]]
478
+name = "django-celery-results"
479
+version = "2.6.0"
480
+source = { registry = "https://pypi.org/simple" }
481
+dependencies = [
482
+ { name = "celery" },
483
+ { name = "django" },
484
+]
485
+sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" }
486
+wheels = [
487
+ { url = "https://files.pythonhosted.org/packages/2c/da/70f0f3c5364735344c4bc89e53413bcaae95b4fc1de4e98a7a3b9fb70c88/django_celery_results-2.6.0-py3-none-any.whl", hash = "sha256:b9ccdca2695b98c7cbbb8dea742311ba9a92773d71d7b4944a676e69a7df1c73", size = 38351, upload-time = "2025-04-10T08:23:49.965Z" },
488
+]
489
+
490
+[[package]]
491
+name = "django-constance"
492
+version = "4.3.5"
493
+source = { registry = "https://pypi.org/simple" }
494
+sdist = { url = "https://files.pythonhosted.org/packages/9c/95/8eff746544ba8958f431f4cfb162fd632db5f914b05b6a355a98eaf45cfd/django_constance-4.3.5.tar.gz", hash = "sha256:081177483d272b664cf768deae76fc2fbb0a777076f45620b6fde4d9075ee2b3", size = 181943, upload-time = "2026-03-15T11:23:50.799Z" }
495
+wheels = [
496
+ { url = "https://files.pythonhosted.org/packages/61/aa/3ff4198d02c0cb23c595fcfbed364bcf03b80f9109b7b971eaa34604c349/django_constance-4.3.5-py3-none-any.whl", hash = "sha256:c28f360c2822112772a3e4caf02db758c82cca8de7d0b9f648fef371bf4b8bc6", size = 66907, upload-time = "2026-03-15T11:23:49.166Z" },
497
+]
498
+
499
+[[package]]
500
+name = "django-cors-headers"
501
+version = "4.9.0"
502
+source = { registry = "https://pypi.org/simple" }
503
+dependencies = [
504
+ { name = "asgiref" },
505
+ { name = "django" },
506
+]
507
+sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
508
+wheels = [
509
+ { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
510
+]
511
+
512
+[[package]]
513
+name = "django-health-check"
514
+version = "4.2.1"
515
+source = { registry = "https://pypi.org/simple" }
516
+dependencies = [
517
+ { name = "django" },
518
+ { name = "dnspython" },
519
+]
520
+sdist = { url = "https://files.pythonhosted.org/packages/85/fb/fd4f1122c7be1ca28eb9c279b6098432b49565d55041dbe96ebcf815d5c2/django_health_check-4.2.1.tar.gz", hash = "sha256:aa05f57d6b01fe502842273aaa944e988b85d1f58e3ea67b6f98c5f9808a530a", size = 21391, upload-time = "2026-03-20T13:38:01.724Z" }
521
+wheels = [
522
+ { url = "https://files.pythonhosted.org/packages/93/05/d30df07b08194f1d89de44ecba867c467e9cc8e047a4cd7682a994a9468b/django_health_check-4.2.1-py3-none-any.whl", hash = "sha256:7216ba208f82f7587dc0ac0fe4c8f8a1c4d0cebbbae46cff6bd89779b378daf3", size = 26435, upload-time = "2026-03-20T13:38:00.557Z" },
523
+]
524
+
525
+[[package]]
526
+name = "django-import-export"
527
+version = "4.4.0"
528
+source = { registry = "https://pypi.org/simple" }
529
+dependencies = [
530
+ { name = "diff-match-patch" },
531
+ { name = "django" },
532
+ { name = "tablib" },
533
+]
534
+sdist = { url = "https://files.pythonhosted.org/packages/22/26/279bc8e6cb2c83d1b5dcdca07e932207c3352af11c6d305d6964a2d03ccc/django_import_export-4.4.0.tar.gz", hash = "sha256:9900e99c89027594941074fb4cd63a5f2964975e239021765c0f066003fcd412", size = 2237714, upload-time = "2026-01-10T20:57:35.128Z" }
535
+wheels = [
536
+ { url = "https://files.pythonhosted.org/packages/2f/e0/f4aa6d2374cc6b53b23f36bd0d5814e1db2769b25931b9908723fa295bb0/django_import_export-4.4.0-py3-none-any.whl", hash = "sha256:2d9b234c0f024d3377167f4d9c5a506e095c5bad98e06d30700e1d0752829e3d", size = 157449, upload-time = "2026-01-10T20:57:33.141Z" },
537
+]
538
+
539
+[[package]]
540
+name = "django-ratelimit"
541
+version = "4.1.0"
542
+source = { registry = "https://pypi.org/simple" }
543
+sdist = { url = "https://files.pythonhosted.org/packages/6f/8f/94038fe739b095aca3e4708ecc8a4e77f1fcfd87bed5d6baff43d4c80bc4/django-ratelimit-4.1.0.tar.gz", hash = "sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b", size = 11551, upload-time = "2023-07-24T20:34:32.374Z" }
544
+wheels = [
545
+ { url = "https://files.pythonhosted.org/packages/fb/78/2c59b30cd8bc8068d02349acb6aeed5c4e05eb01cdf2107ccd76f2e81487/django_ratelimit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92", size = 11608, upload-time = "2023-07-24T20:34:31.362Z" },
546
+]
547
+
548
+[[package]]
549
+name = "django-ses"
550
+version = "4.7.2"
551
+source = { registry = "https://pypi.org/simple" }
552
+dependencies = [
553
+ { name = "boto3" },
554
+ { name = "django" },
555
+]
556
+sdist = { url = "https://files.pythonhosted.org/packages/7f/25/25838da8e213c9f125b26a25360f0bb8ac57f07c24977451f3e7a0d63ddd/django_ses-4.7.2.tar.gz", hash = "sha256:a36f2af0e4ce060bf36053ed4c94feac1703ea3351e677c6f6421abd01433a35", size = 71828, upload-time = "2026-02-20T19:22:35.078Z" }
557
+wheels = [
558
+ { url = "https://files.pythonhosted.org/packages/84/f2/15d4bd54bd01e68e8a116e0b66243c91cb62a3fa0d780a39d2af6654e8ae/django_ses-4.7.2-py3-none-any.whl", hash = "sha256:f3db567fb6f43c01d7d890f5c991e1ebbfa48220de0be24d497ba6332004abcb", size = 37796, upload-time = "2026-02-20T19:22:32.819Z" },
559
+]
560
+
561
+[[package]]
562
+name = "django-simple-history"
563
+version = "3.11.0"
564
+source = { registry = "https://pypi.org/simple" }
565
+dependencies = [
566
+ { name = "django" },
567
+]
568
+sdist = { url = "https://files.pythonhosted.org/packages/a8/11/410049f1454b99a78f719d3403fc89437c2a38ee092e939d5ab8d4846738/django_simple_history-3.11.0.tar.gz", hash = "sha256:2c587479cf2c3071e9aa555d0d11b73676994db4910770958f57659ade2deffe", size = 234862, upload-time = "2025-12-11T13:50:55.022Z" }
569
+wheels = [
570
+ { url = "https://files.pythonhosted.org/packages/6e/c2/e9854a3438cfc80891ab4d3826b7c61a0fe5ba3a4da89104a8f5c9afb5df/django_simple_history-3.11.0-py3-none-any.whl", hash = "sha256:f3c298db49e418ffce7fb709a5e83108452ea2179ec5c4b9232484c25427192a", size = 81868, upload-time = "2025-12-11T13:50:53.71Z" },
571
+]
572
+
573
+[[package]]
574
+name = "django-storages"
575
+version = "1.14.6"
576
+source = { registry = "https://pypi.org/simple" }
577
+dependencies = [
578
+ { name = "django" },
579
+]
580
+sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" }
581
+wheels = [
582
+ { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
583
+]
584
+
585
+[package.optional-dependencies]
586
+s3 = [
587
+ { name = "boto3" },
588
+]
589
+
590
+[[package]]
591
+name = "django-timezone-field"
592
+version = "7.2.1"
593
+source = { registry = "https://pypi.org/simple" }
594
+dependencies = [
595
+ { name = "django" },
596
+]
597
+sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" }
598
+wheels = [
599
+ { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" },
600
+]
601
+
602
+[[package]]
603
+name = "dnspython"
604
+version = "2.8.0"
605
+source = { registry = "https://pypi.org/simple" }
606
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
607
+wheels = [
608
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
609
+]
610
+
611
+[[package]]
612
+name = "filelock"
613
+version = "3.25.2"
614
+source = { registry = "https://pypi.org/simple" }
615
+sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
616
+wheels = [
617
+ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
618
+]
619
+
620
+[[package]]
621
+name = "fossilrepo"
622
+version = "0.1.0"
623
+source = { editable = "." }
624
+dependencies = [
625
+ { name = "boto3" },
626
+ { name = "celery", extra = ["redis"] },
627
+ { name liard"
628
+version = "4.2.4"
629
+version = 1
630
+revision = 3
631
+requires-python = ">=3.12"
632
+
633
+[[package]]
634
+name = "amqp"
635
+version = "5.3.1"
636
+source = { registry = "https://pypi.org/simple" }
637
+dependencies = [
638
+ { name = "vine" },
639
+]
640
+sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
641
+wheels = [
642
+ { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
643
+]
644
+
645
+[[package]]
646
+name = "asgiref"
647
+version = "3.11.1"
648
+source = { registry = "https://pypi.org/simple" }
649
+sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4cversio},
650
+ { name = "django-ses", specifier = ">=4.1" },
651
+ { name = "django-simple-history", specifier = ">=3.7" },
652
+ { name = "django-storages", extras = ["s3"], specifier = ">=1.14" },
653
+ { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" },
654
+ { name = "gunicorn", specifier = ">=23.0" },
655
+ { name = "markdown", specifier = ">=3.6" },
656
+ { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
657
+ { name = "psycopg2-binary", specifier = ">=2.9" },
658
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
659
+ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
660
+ { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" },
661
+ { name = "redis", specifier = ">=5.0" },
662
+ { name = "requests", specifier = ">=2.31" },
663
+ { name = "rich", specifier = ">=13.0" },
664
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
665
+ { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" },
666
+ { name = "whitenoise", specifier = ">=6.7" },
667
+]
668
+provides-extras = ["dev"]
669
+
670
+[[package]]
671
+name = "freezegun"
672
+version = "1.5.5"
673
+source = { registry = "https://pypi.orich size = 342432, upl8e1c137dfebd5759a2ist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
674
+wheels = [
675
+ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
676
+]
677
+
678
+[[package]]
679
+name = "gunicorn"
680
+version = "25.2.0"
681
+source = { registry = "https://pypi.org/simple" }
682
+dependencies = [
683
+ { name = "packaging" },
684
+]
685
+sdist = { url = "https://files.pythonhosted.org/packages/dd/13/dd3f8e40ea3ee907a6cbf3d1f1f81afcc3ecd0087d313baabfe95372f15c/gunicorn-25.2.0.tar.gz", hash = "sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c", size = 632709, upload-time = "2026-03-24T22:49:54.433Z" }
686
+wheels = [
687
+ { url = "https://files.pythonhosted.org/packages/11/53/fb024445837e02cd5cf989cf349bfac6f3f433c05184ea5d49c8ade751c6/gunicorn-25.2.0-py3-none-any.whl", hash = "sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc", size = 211659, upload-time = "2026-03-24T22:49:52.528Z" },
688
+]
689
+
690
+[[package]]
691
+name = "idna"
692
+version = "3.11"
693
+source = { registry = "https://pypi.org/simple" }
694
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
695
+wheels = [
696
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b5ichpload-time = "20251e = "2026-01-22T16:35:ruff1.1.0.tar.gz", has0.7 size = 342432, upl8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" }
697
+wheels = [
698
+ { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" },
699
+]
700
+
701
+[package.optional-dependencies]
702
+redis = [
703
+ { name = "redis" },
704
+]
705
+
706
+[[package]]
707
+name = "license-expression"
708
+version = "30.4.4"
709
+source = { registry = "https://pypi.org/simple" }
710
+dependencies = [
711
+ { name = "boolean-py" },
712
+]
713
+sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
714
+wheels = [
715
+ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
716
+]
717
+
718
+[[package]]
719
+name = "markdown"
720
+version = "3.10.2"
721
+source = { registry = "https://pypi.org/simple" }
722
+sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
723
+wheels = [
724
+ { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
725
+]
726
+
727
+[[package]]
728
+name = "markdown-it-py"
729
+version = "4.0.0"
730
+source = { registry = "https://pypi.org/simple" }
731
+dependencies = [
732
+ { name = "mdurl" },
733
+]
734
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
735
+wheels = [
736
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
737
+]
738
+
739
+[[package]]
740
+name = "mdurl"
741
+version = "0.1.2"
742
+source = { registry = "https://pypi.org/simple" }
743
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
744
+wheels = [
745
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
746
+]
747
+
748
+[[package]]
749
+name = "msgpack"
750
+version = "1.1.2"
751
+source = { registry = "https://pypi.org/simple" }
752
+sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
753
+wheels = [
754
+ { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
755
+ { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
756
+ { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
757
+ { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
758
+ { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
759
+ { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
760
+ { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
761
+ { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
762
+ { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
763
+ { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
764
+ { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
765
+ { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
766
+ { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
767
+ { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
768
+ { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
769
+ { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
770
+ { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
771
+ { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
772
+ { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
773
+ { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
774
+ { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
775
+ { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
776
+ { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
777
+ { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
778
+ { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
779
+ { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
780
+ { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
781
+ { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
782
+ { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
783
+ { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
784
+ { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
785
+ { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
786
+ { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
787
+ { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
788
+ { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
789
+ { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
790
+]
791
+
792
+[[package]]
793
+name = "packageurl-python"
794
+version = "0.17.6"
795
+source = { registry = "https://pypi.org/simple" }
796
+sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
797
+wheels = [
798
+ { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
799
+]
800
+
801
+[[package]]
802
+name = "packaging"
803
+version = "26.0"
804
+source = { registry = "https://pypi.org/simple" }
805
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
806
+wheels = [
807
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
808
+]
809
+
810
+[[package]]
811
+name = "pip"
812
+version = "26.0.1"
813
+source = { registry = "https://pypi.org/simple" }
814
+sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
815
+wheels = [
816
+ { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
817
+]
818
+
819
+[[package]]
820
+name = "pip-api"
821
+version = "0.0.34"
822
+source = { registry = "https://pypi.org/simple" }
823
+dependencies = [
824
+ { name = "pip" },
825
+]
826
+sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" }
827
+wheels = [
828
+ { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" },
829
+]
830
+
831
+[[package]]
832
+name = "pip-audit"
833
+version = "2.10.0"
834
+source = { registry = "https://pypi.org/simple" }
835
+dependencies = [
836
+ { name = "cachecontrol", extra = ["filecache"] },
837
+ { name = "cyclonedx-python-lib" },
838
+ { name = "packaging" },
839
+ { name = "pip-api" },
840
+ { name = "pip-requirements-parser" },
841
+ { name = "platformdirs" },
842
+ { name = "requests" },
843
+ { name = "rich" },
844
+ { name = "tomli" },
845
+ { name = "tomli-w" },
846
+]
847
+sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" }
848
+wheels = [
849
+ { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" },
850
+]
851
+
852
+[[package]]
853
+name = "pip-requirements-parser"
854
+version = "32.0.1"
855
+source = { registry = "https://pypi.org/simple" }
856
+dependencies = [
857
+ { name = "packaging" },
858
+ { name = "pyparsing" },
859
+]
860
+sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
861
+wheels = [
862
+ { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
863
+]
864
+
865
+[[package]]
866
+name = "platformdirs"
867
+version = "4.9.4"
868
+source = { registry = "https://pypi.org/simple" }
869
+sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
870
+wheels = [
871
+ { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
872
+]
873
+
874
+[[package]]
875
+name = "pluggy"
876
+version = "1.6.0"
877
+source = { registry = "https://pypi.org/simple" }
878
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
879
+wheels = [
880
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
881
+]
882
+
883
+[[package]]
884
+name = "prompt-toolkit"
885
+version = "3.0.52"
886
+source = { registry = "https://pypi.org/simple" }
887
+dependencies = [
888
+ { name = "wcwidth" },
889
+]
890
+sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
891
+wheels = [
892
+ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
893
+]
894
+
895
+[[package]]
896
+name = "psycopg2-binary"
897
+version = "2.9.11"
898
+source = { registry = "https://pypi.org/simple" }
899
+sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
900
+wheels = [
901
+ { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
902
+ { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
903
+ { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
904
+ { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
905
+ { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
906
+ { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
907
+ { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
908
+ { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
909
+ { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
910
+ { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
911
+ { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
912
+ { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
913
+ { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
914
+ { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
915
+ { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
916
+ { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
917
+ { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
918
+ { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
919
+ { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
920
+ { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
921
+ { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
922
+ { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
923
+ { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
924
+ { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
925
+ { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
926
+ { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
927
+ { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
928
+ { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
929
+ { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
930
+ { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
931
+ { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
932
+ { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
933
+ { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
934
+]
935
+
936
+[[package]]
937
+name = "py-serializable"
938
+version = "2.1.0"
939
+source = { registry = "https://pypi.org/simple" }
940
+dependencies = [
941
+ { name = "defusedxml" },
942
+]
943
+sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
944
+wheels = [
945
+ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
946
+]
947
+
948
+[[package]]
949
+name = "pycparser"
950
+version = "3.0"
951
+source = { registry = "https://pypi.org/simple" }
952
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
953
+wheels = [
954
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
955
+]
956
+
957
+[[package]]
958
+name = "pygments"
959
+version = "2.19.2"
960
+source = { registry = "https://pypi.org/simple" }
961
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
962
+wheels = [
963
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
964
+]
965
+
966
+[[package]]
967
+name = "pyparsing"
968
+version = "3.3.2"
969
+source = { registry = "https://pypi.org/simple" }
970
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
971
+wheels = [
972
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
973
+]
974
+
975
+[[package]]
976
+name = "pytest"
977
+version = "9.0.2"
978
+source = { registry = "https://pypi.org/simple" }
979
+dependencies = [
980
+ { name = "colorama", marker = "sys_platform == 'win32'" },
981
+ { name = "iniconfig" },
982
+ { name = "packaging" },
983
+ { name = "pluggy" },
984
+ { name = "pygments" },
985
+]
986
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
987
+wheels = [
988
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
989
+]
990
+
991
+[[package]]
992
+name = "pytest-cov"
993
+version = "7.1.0"
994
+source = { registry = "https://pypi.org/simple" }
995
+dependencies = [
996
+ { name = "coverage" },
997
+ { name = "pluggy" },
998
+ { name = "pytest" },
999
+]
1000
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
1001
+wheels = [
1002
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
1003
+]
1004
+
1005
+[[package]]
1006
+name = "pytest-django"
1007
+version = "4.12.0"
1008
+source = { registry = "https://pypi.org/simple" }
1009
+dependencies = [
1010
+ { name = "pytest" },
1011
+]
1012
+sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" }
1013
+wheels = [
1014
+ { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
1015
+]
1016
+
1017
+[[package]]
1018
+name = "python-crontab"
1019
+version = "3.3.0"
1020
+source = { registry = "https://pypi.org/simple" }
1021
+sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" }
1022
+wheels = [
1023
+ { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" },
1024
+]
1025
+
1026
+[[package]]
1027
+name = "python-dateutil"
1028
+version = "2.9.0.post0"
1029
+source = { registry = "https://pypi.org/simple" }
1030
+dependencies = [
1031
+ { name = "six" },
1032
+]
1033
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
1034
+wheels = [
1035
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
1036
+]
1037
+
1038
+[[package]]
1039
+name = "redis"
1040
+version = "6.4.0"
1041
+source = { registry = "https://pypi.org/simple" }
1042
+sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
1043
+wheels = [
1044
+ { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
1045
+]
1046
+
1047
+[[package]]
1048
+name = "requests"
1049
+version = "2.33.0"
1050
+source = { registry = "https://pypi.org/simple" }
1051
+dependencies = [
1052
+ { name = "certifi" },
1053
+ { name = "charset-normalizer" },
1054
+ { name = "idna" },
1055
+ { name = "urllib3" },
1056
+]
1057
+sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
1058
+wheels = [
1059
+ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
1060
+]
1061
+
1062
+[[package]]
1063
+name = "rich"
1064
+version = "14.3.3"
1065
+source = { registry = "https://pypi.org/simple" }
1066
+dependencies = [
1067
+ { name = "markdown-it-py" },
1068
+ { name = "pygments" },
1069
+]
1070
+sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
1071
+wheels = [
1072
+ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
1073
+]
1074
+
1075
+[[package]]
1076
+name = "ruff"
1077
+version = "0.15.7"
1078
+source = { registry = "https://pypi.org/simple" }
1079
+sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
1080
+wheels = [
1081
+ { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
1082
+ { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
1083
+ { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
1084
+ { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
1085
+ { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
1086
+ { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
1087
+ { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
1088
+ { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
1089
+ { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
1090
+ { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
1091
+ { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
1092
+ { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
1093
+ { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
1094
+ { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
1095
+ { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
1096
+ { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
1097
+ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
1098
+]
1099
+
1100
+[[package]]
1101
+name = "s3transfer"
1102
+version = "0.16.0"
1103
+source = { registry = "https://pypi.org/simple" }
1104
+dependencies = [
1105
+ { name = "botocore" },
1106
+]
1107
+sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
1108
+wheels = [
1109
+ { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
1110
+]
1111
+
1112
+[[package]]
1113
+name = "sentry-sdk"
1114
+version = "2.56.0"
1115
+source = { registry = "https://pypi.org/simple" }
1116
+dependencies = [
1117
+ { name = "certifi" },
1118
+ { name = "urllib3" },
1119
+]
1120
+sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" }
1121
+wheels = [
1122
+ { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" },
1123
+]
1124
+
1125
+[package.optional-dependencies]
1126
+django = [
1127
+ { name = "django" },
1128
+]
1129
+
1130
+[[package]]
1131
+name = "six"
1132
+version = "1.17.0"
1133
+source = { registry = "https://pypi.org/simple" }
1134
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
1135
+wheels = [
1136
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
1137
+]
1138
+
1139
+[[package]]
1140
+name = "sortedcontainers"
1141
+version = "2.4.0"
1142
+source = { registry = "https://pypi.org/simple" }
1143
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
1144
+wheels = [
1145
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
1146
+]
1147
+
1148
+[[package]]
1149
+name = "sqlparse"
1150
+version = "0.5.5"
1151
+source = { registry = "https://pypi.org/simple" }
1152
+sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
1153
+wheels = [
1154
+ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
1155
+]
1156
+
1157
+[[package]]
1158
+name = "tablib"
1159
+version = "3.9.0"
1160
+source = { registry = "https://pypi.org/simple" }
1161
+sdist = { url = "https://files.pythonhosted.org/packages/11/00/416d2ba54d7d58a7f7c61bf62dfeb48fd553cf49614daf83312f2d2c156e/tablib-3.9.0.tar.gz", hash = "sha256:1b6abd8edb0f35601e04c6161d79660fdcde4abb4a54f66cc9f9054bd55d5fe2", size = 125565, upload-time = "2025-10-15T18:21:56.263Z" }
1162
+wheels = [
1163
+ { url = "https://files.pythonhosted.org/packages/66/6b/32e51d847148b299088fc42d3d896845fd09c5247190133ea69dbe71ba51/tablib-3.9.0-py3-none-any.whl", hash = "sha256:eda17cd0d4dda614efc0e710227654c60ddbeb1ca92cdcfc5c3bd1fc5f5a6e4a", size = 49580, upload-time = "2025-10-15T18:21:44.185Z" },
1164
+]
1165
+
1166
+[[package]]
1167
+name = "tomli"
1168
+version = "2.4.1"
1169
+source = { registry = "https://pypi.org/simple" }
1170
+sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
1171
+wheels = [
1172
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
1173
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
1174
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
1175
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
1176
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
1177
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
1178
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
1179
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
1180
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
1181
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
1182
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
1183
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
1184
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
1185
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
1186
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
1187
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
1188
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
1189
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
1190
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
1191
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
1192
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
1193
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
1194
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
1195
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
1196
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
1197
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
1198
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
1199
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
1200
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
1201
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
1202
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
1203
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
1204
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
1205
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
1206
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
1207
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
1208
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
1209
+]
1210
+
1211
+[[package]]
1212
+name = "tomli-w"
1213
+version = "1.2.0"
1214
+source = { registry = "https://pypi.org/simple" }
1215
+sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
1216
+wheels = [
1217
+ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
1218
+]
1219
+
1220
+[[package]]
1221
+name = "typing-extensions"
1222
+version = "4.15.0"
1223
+source = { registry = "https://pypi.org/simple" }
1224
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
1225
+wheels = [
1226
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
1227
+]
1228
+
1229
+[[package]]
1230
+name = "tzdata"
1231
+version = "2025.3"
1232
+source = { registry = "https://pypi.org/simple" }
1233
+sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
1234
+wheels = [
1235
+ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
1236
+]
1237
+
1238
+[[package]]
1239
+name = "tzlocal"
1240
+version = "5.3.1"
1241
+source = { registry = "https://pypi.org/simple" }
1242
+dependencies = [
1243
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
1244
+]
1245
+sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
1246
+wheels = [
1247
+ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
1248
+]
1249
+
1250
+[[package]]
1251
+name = "urllib3"
1252
+version = "2.6.3"
1253
+source = { registry = "https://pypi.org/simple" }
1254
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
1255
+wheels = [
1256
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
1257
+]
1258
+
1259
+[[package]]
1260
+name = "vine"
1261
+version = "5.1.0"
1262
+source = { registry = "https://pypi.org/simple" }
1263
+sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" }
1264
+wheels = [
1265
+ { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
1266
+]
1267
+
1268
+[[package]]
1269
+name = "wcwidth"
1270
+version = "0.6.0"
1271
+source = { registry = "https://pypi.org/simple" }
1272
+sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
1273
+wheels = [
1274
+ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
1275
+]
1276
+
1277
+[[package]]
1278
+name = "whitenoise"
1279
+version = "6.12.0"
1280
+source = { registry = "https://pypi.org/simple" }
1281
+sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
1282
+wheels = [
1283
+ { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
1284
+]
1285
+5.1,<6.0" },
1286
+ { name = "django-celery-beat", specifier = ">=2.7" },
1287
+ { name = "django-celery-results", specifier = ">=2.5" },
1288
+ { name = "django-constance", extras = ["database"], specifier = ">=4.1" },
1289
+ { name = "django-cors-headers", specifier = ">=4.4" },
1290
+ { name = "django-health-check", specifier = ">=3.18" },
1291
+ { name = "django-import-export", specifier = ">=4.0" },
1292
+ { name = "django-ratelimit", specifier = ">=4.1" },
1293
+ { name = "django-ses", specifier = ">=4.1" },
1294
+ { name = "django-simple-history", specifier = ">=3.7" },
1295
+ { name = "django-storages", extras = ["s3"], specifier = ">=1.14" },
1296
+ { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" },
1297
+ { name = "gunicorn", specifier = ">=23.0" },
1298
+ { name = "markdown", specifier = ">=3.6" },
1299
+ { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
1300
+ { name = "psycopg2-binary", specifier = ">=2.9" },
1301
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
1302
+ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
1303
+ { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" },
1304
+ { name = "redis", specifier = ">=5.0" },
1305
+ { name = "requests", specifier = ">=2.31" },
1306
+ { name = "rich", specifier = ">=13.0" },
1307
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
1308
+ { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" },
1309
+ { name = "whitenoise", specifier = ">=6.7" },
1310
+]
1311
+provides-extras = ["dev"]
1312
+
1313
+[[package]]
1314
+name = "freezegun"
1315
+version = "1.5.5"
1316
+source = { registry = "https://pypi.org/simple" }
1317
+dependencies = [
1318
+ { name = "python-dateutil" },
1319
+]
1320
+sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
1321
+wheels = [
1322
+ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
1323
+]
1324
+
1325
+[[package]]
1326
+name = "gunicorn"
1327
+version = "25.2.0"
1328
+source = { registry = "https://pypi.org/simple" }
1329
+dependencies = [
1330
+ { name = "packaging" },
1331
+]
1332
+sdist = { url = "https://files.pythonhosted.org/packages/dd/13/dd3f8e40ea3ee907a6cbf3d1f1f81afcc3ecd0087d313baabfe95372f15c/gunicorn-25.2.0.tar.gz", hash = "sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c", size = 632709, upload-time = "2026-03-24T22:49:54.433Z" }
1333
+wheels = [
1334
+ { url = "https://files.pythonhosted.org/packages/11/53/fb024445837e02cd5cf989cf349bfac6f3f433c05184ea5d49c8ade751c6/gunicorn-25.2.0-py3-none-any.whl", hash = "sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc", size = 211659, upload-time = "2026-03-24T22:49:52.528Z" },
1335
+]
1336
+
1337
+[[package]]
1338
+name = "idna"
1339
+version = "3.11"
1340
+source = { registry = "https://pypi.org/simple" }
1341
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c0
--- a/uv.lock
+++ b/uv.lock
@@ -0,0 +1,1341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/uv.lock
+++ b/uv.lock
@@ -0,0 +1,1341 @@
1 version = 1
2 revision = 3
3 requires-python = ">=3.12"
4
5 [[package]]
6 name = "amqp"
7 version = "5.3.1"
8 source = { registry = "https://pypi.org/simple" }
9 dependencies = [
10 { name = "vine" },
11 ]
12 sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
13 wheels = [
14 { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
15 ]
16
17 [[package]]
18 name = "asgiref"
19 version = "3.11.1"
20 source = { registry = "https://pypi.org/simple" }
21 sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
22 wheels = [
23 { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
24 ]
25
26 [[package]]
27 name = "billiard"
28 version = "4.2.4"
29 source = { registry = "https://pypi.org/simple" }
30 sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
31 wheels = [
32 { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
33 ]
34
35 [[package]]
36 name = "boolean-py"
37 version = "5.0"
38 source = { registry = "https://pypi.org/simple" }
39 sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
40 wheels = [
41 { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
42 ]
43
44 [[package]]
45 name = "boto3"
46 version = "1.42.76"
47 source = { registry = "https://pypi.org/simple" }
48 dependencies = [
49 { name = "botocore" },
50 { name = "jmespath" },
51 { name = "s3transfer" },
52 ]
53 sdist = { url = "https://files.pythonhosted.org/packages/f1/13/33c8b8704d677fcaf5555ba8c6cc39468fc7b9a0c6b6c496e008cd5557fc/boto3-1.42.76.tar.gz", hash = "sha256:aa2b1973eee8973a9475d24bb579b1dee7176595338d4e4f7880b5c6189b8814", size = 112789, upload-time = "2026-03-25T19:33:25.985Z" }
54 wheels = [
55 { url = "https://files.pythonhosted.org/packages/f0/dc/21b3dfb135125eb7e3a46b9aab0aede847726f239fc8f39474742a87ebb0/boto3-1.42.76-py3-none-any.whl", hash = "sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e", size = 140557, upload-time = "2026-03-25T19:33:23.289Z" },
56 ]
57
58 [[package]]
59 name = "botocore"
60 version = "1.42.76"
61 source = { registry = "https://pypi.org/simple" }
62 dependencies = [
63 { name = "jmespath" },
64 { name = "python-dateutil" },
65 { name = "urllib3" },
66 ]
67 sdist = { url = "https://files.pythonhosted.org/packages/70/62/a982acb81c5e0312f90f841b790abad65622c08aad356eed7008ea3d475b/botocore-1.42.76.tar.gz", hash = "sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874", size = 15021811, upload-time = "2026-03-25T19:33:12.171Z" }
68 wheels = [
69 { url = "https://files.pythonhosted.org/packages/f5/63/7429d68876b7718ab5c4b8a44414de7907f5ba6bb27ccfad384df14fb277/botocore-1.42.76-py3-none-any.whl", hash = "sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607", size = 14697736, upload-time = "2026-03-25T19:33:07.573Z" },
70 ]
71
72 [[package]]
73 name = "cachecontrol"
74 version = "0.14.4"
75 source = { registry = "https://pypi.org/simple" }
76 dependencies = [
77 { name = "msgpack" },
78 { name = "requests" },
79 ]
80 sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
81 wheels = [
82 { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
83 ]
84
85 [package.optional-dependencies]
86 filecache = [
87 { name = "filelock" },
88 ]
89
90 [[package]]
91 name = "version = 1
92 revisiversion = 1
93 revision = 3
94 requires-python = ">=3.12"
95
96 [[package]]
97 name = "amqp"
98 version = "5.3.1"
99 source = { registry = "https://pypi.org/simple" }
100 dependencies = [
101 { name = "vine" },
102 ]
103 sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
104 wheels = [
105 { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
106 ]
107
108 [[package]]
109 name = "asgiref"
110 version = "3.11.1"
111 source = { registry = "https://pypi.org/simple" }
112 sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
113 wheels = [
114 { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
115 ]
116
117 [[package]]
118 name = "billiard"
119 version = "4.2.4"
120 source = { registry = "https://pypi.org/simple" }
121 sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" }
122 wheels = [
123 { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" },
124 ]
125
126 [[package]]
127 name = "boolean-py"
128 version = "5.0"
129 source = { registry = "https://pypi.org/simple" }
130 sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
131 wheels = [
132 { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
133 ]
134
135 [[package]]
136 name = "boto3"
137 version = "1.42.76"
138 source = { registry = "https://pypi.org/simple" }
139 dependencies = [
140 { name = "botocore" },
141 { name = "jmespath" },
142 { name = "s3transfer" },
143 ]
144 sdist = { url = "https://files.pythonhosted.org/packages/f1/13/33c8b8704d677fcaf5555ba8c6cc39468fc7b9a0c6b6c496e008cd5557fc/boto3-1.42.76.tar.gz", hash = "sha256:aa2b1973eee8973a9475d24bb579b1dee7176595338d4e4f7880b5c6189b8814", size = 112789, upload-time = "2026-03-25T19:33:25.985Z" }
145 wheels = [
146 { url = "https://files.pythonhosted.org/packages/f0/dc/21b3dfb135125eb7e3a46b9aab0aede847726f239fc8f39474742a87ebb0/boto3-1.42.76-py3-none-any.whl", hash = "sha256:63c6779c814847016b89ae1b72ed968f8a63d80e589ba337511aa6fc1b59585e", size = 140557, upload-time = "2026-03-25T19:33:23.289Z" },
147 ]
148
149 [[package]]
150 name = "botocore"
151 version = "1.42.76"
152 source = { registry = "https://pypi.org/simple" }
153 dependencies = [
154 { name = "jmespath" },
155 { name = "python-dateutil" },
156 { name = "urllib3" },
157 ]
158 sdist = { url = "https://files.pythonhosted.org/packages/70/62/a982acb81c5e0312f90f841b790abad65622c08aad356eed7008ea3d475b/botocore-1.42.76.tar.gz", hash = "sha256:c553fa0ae29e36a5c407f74da78b78404b81b74b15fb62bf640a3cd9385f0874", size = 15021811, upload-time = "2026-03-25T19:33:12.171Z" }
159 wheels = [
160 { url = "https://files.pythonhosted.org/packages/f5/63/7429d68876b7718ab5c4b8a44414de7907f5ba6bb27ccfad384df14fb277/botocore-1.42.76-py3-none-any.whl", hash = "sha256:151e714ae3c32f68ea0b4dc60751401e03f84a87c6cf864ea0ee64aa10eb4607", size = 14697736, upload-time = "2026-03-25T19:33:07.573Z" },
161 ]
162
163 [[package]]
164 name = "cachecontrol"
165 version = "0.14.4"
166 source = { registry = "https://pypi.org/simple" }
167 dependencies = [
168 { name = "msgpack" },
169 { name = "requests" },
170 ]
171 sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
172 wheels = [
173 { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
174 ]
175
176 [package.optional-dependencies]
177 filecache = [
178 { name = "filelock" },
179 ]
180
181 [[package]]
182 name = "celery"
183 version = "5.6.3"
184 source = { registry = "https://pypi.org/simple" }
185 dependencies = [
186 { name = "billiard" },
187 { name = "click" },
188 { name = "click-didyoumean" },
189 { name = "click-plugins" },
190 { name = "click-repl" },
191 { name = "kombu" },
192 { name = "python-dateutil" },
193 { name = "tzlocal" },
194 { name = "vine" },
195 ]
196 sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" }
197 wheels = [
198 { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" },
199 ]
200
201 [package.optional-dependencies]
202 redis = [
203 { name = "kombu", extra = ["redis"] },
204 ]
205
206 [[package]]
207 name = "certifi"
208 version = "2026.2.25"
209 source = { registry = "https://pypi.org/simple" }
210 sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
211 wheels = [
212 { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
213 ]
214
215 [[package]]
216 name = "cffi"
217 version = "2.0.0"
218 source = { registry = "https://pypi.org/simple" }
219 dependencies = [
220 { name = "pycparser", marker = "implementation_name != 'PyPy'" },
221 ]
222 sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
223 wheels = [
224 { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
225 { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
226 { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
227 { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
228 { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
229 { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
230 { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
231 { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
232 { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
233 { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
234 { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
235 { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
236 { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
237 { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
238 { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
239 { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
240 { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
241 { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
242 { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
243 { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
244 { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
245 { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
246 { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
247 { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
248 { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
249 { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
250 { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
251 { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
252 { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
253 { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
254 { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
255 { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
256 { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
257 { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
258 { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
259 { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
260 { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
261 { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
262 { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
263 { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
264 { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
265 { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
266 { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
267 { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
268 { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
269 { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
270 ]
271
272 [[package]]
273 name = "charset-normalizer"
274 version = "3.4.6"
275 source = { registry = "https://pypi.org/simple" }
276 sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
277 wheels = [
278 { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" },
279 { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" },
280 { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" },
281 { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" },
282 { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" },
283 { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" },
284 { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" },
285 { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" },
286 { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" },
287 { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" },
288 { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" },
289 { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" },
290 { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" },
291 { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" },
292 { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" },
293 { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" },
294 { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
295 { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
296 { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
297 { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
298 { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
299 { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
300 { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
301 { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
302 { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
303 { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
304 { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
305 { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
306 { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
307 { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
308 { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
309 { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
310 { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
311 { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
312 { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
313 { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
314 { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
315 { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
316 { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
317 { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
318 { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
319 { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
320 { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
321 { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
322 { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
323 { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
324 { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
325 { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
326 { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
327 { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
328 { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
329 { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
330 { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
331 { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
332 { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
333 { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
334 { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
335 { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
336 { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
337 { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
338 { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
339 { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
340 { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
341 { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
342 { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
343 ]
344
345 [[package]]
346 name = "click"
347 version = "8.3.1"
348 source = { registry = "https://pypi.org/simple" }
349 dependencies = [
350 { name = "colorama", marker = "sys_platform == 'win32'" },
351 ]
352 sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
353 wheels = [
354 { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
355 ]
356
357 [[package]]
358 name = "click-didyoumean"
359 version = "0.3.1"
360 source = { registry = "https://pypi.org/simple" }
361 dependencies = [
362 { name = "click" },
363 ]
364 sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" }
365 wheels = [
366 { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" },
367 ]
368
369 [[package]]
370 name = "click-plugins"
371 version = "1.1.1.2"
372 source = { registry = "https://pypi.org/simple" }
373 dependencies = [
374 { name = "click" },
375 ]
376 sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" }
377 wheels = [
378 { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" },
379 ]
380
381 [[package]]
382 name = "click-repl"
383 version = "0.3.0"
384 source = { registry = "https://pypi.org/simple" }
385 dependencies = [
386 { name = "click" },
387 { name = "prompt-toolkit" },
388 ]
389 sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" }
390 wheels = [
391 { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" },
392 ]
393
394 [[package]]
395 name = "colorama"
396 version = "0.4.6"
397 source = { registry = "https://pypi.org/simple" }
398 sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
399 wheels = [
400 { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
401 ]
402
403 [[package]]
404 name = "coverage"
405 version = "7.13.5"
406 source = { registry = "https://pypi.org/simple" }
407 sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
408 wheels = [
409 { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
410 { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
411 { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
412 { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
413 { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964dyclonedx-python-lib"
414 version = "11.7.0"
415 source = { registry = "https://pypi.org/simple" }
416 dependencies = [
417 { name = "license-expression" },
418 { name = "packageurl-python" },
419 { name = "py-serializable" },
420 { name = "sortedcontainers" },
421 { name = "typing-extensions", marker = "python_full_version < '3.13'" },
422 ]
423 sdist = { url = "https://files.pythonhosted.org/packages/21/0d/64f02d3fd9c116d6f50a540d04d1e4f2e3c487f5062d2db53733ddb25917/cyclonedx_python_lib-11.7.0.tar.gz", hash = "sha256:fb1bc3dedfa31208444dbd743007f478ab6984010a184e5bd466bffd969e936e", size = 1411174, upload-time = "2026-03-17T15:19:16.606Z" }
424 wheels = [
425 { url = "https://files.pythonhosted.org/packages/30/09/fe0e3bc32bd33707c519b102fc064ad2a2ce5a1b53e2be38b86936b476b1/cyclonedx_python_lib-11.7.0-py3-none-any.whl", hash = "sha256:02fa4f15ddbba21ac9093039f8137c0d1813af7fe88b760c5dcd3311a8da2178", size = 513041, upload-time = "2026-03-17T15:19:14.369Z" },
426 ]
427
428 [[package]]
429 name = "defusedxml"
430 version = "0.7.1"
431 source = { registry = "https://pypi.org/simple" }
432 sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
433 wheels = [
434 { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
435 ]
436
437 [[package]]
438 name = "diff-match-patch"
439 version = "20241021"
440 source = { registry = "https://pypi.org/simple" }
441 sdist = { url = "https://files.pythonhosted.org/packages/0e/ad/32e1777dd57d8e85fa31e3a243af66c538245b8d64b7265bec9a61f2ca33/diff_match_patch-20241021.tar.gz", hash = "sha256:beae57a99fa48084532935ee2968b8661db861862ec82c6f21f4acdd6d835073", size = 39962, upload-time = "2024-10-21T19:41:21.094Z" }
442 wheels = [
443 { url = "https://files.pythonhosted.org/packages/f7/bb/2aa9b46a01197398b901e458974c20ed107935c26e44e37ad5b0e5511e44/diff_match_patch-20241021-py3-none-any.whl", hash = "sha256:93cea333fb8b2bc0d181b0de5e16df50dd344ce64828226bda07728818936782", size = 43252, upload-time = "2024-10-21T19:41:19.914Z" },
444 ]
445
446 [[package]]
447 name = "django"
448 version = "5.2.12"
449 source = { registry = "https://pypi.org/simple" }
450 dependencies = [
451 { name = "asgiref" },
452 { name = "sqlparse" },
453 { name = "tzdata", marker = "sys_platform == 'win32'" },
454 ]
455 sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" }
456 wheels = [
457 { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" },
458 ]
459
460 [[package]]
461 name = "django-celery-beat"
462 version = "2.9.0"
463 source = { registry = "https://pypi.org/simple" }
464 dependencies = [
465 { name = "celery" },
466 { name = "cron-descriptor" },
467 { name = "django" },
468 { name = "django-timezone-field" },
469 { name = "python-crontab" },
470 { name = "tzdata" },
471 ]
472 sdist = { url = "https://files.pythonhosted.org/packages/05/45/fc97bc1d9af8e7dc07f1e37044d9551a30e6793249864cef802341e2e3a8/django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c", size = 177667, upload-time = "2026-02-28T16:45:34.749Z" }
473 wheels = [
474 { url = "https://files.pythonhosted.org/packages/71/ae/9befa7ae37f5e5c41be636a254fcf47ff30dd5c88bd115070e252f6b9162/django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157", size = 105013, upload-time = "2026-02-28T16:45:32.822Z" },
475 ]
476
477 [[package]]
478 name = "django-celery-results"
479 version = "2.6.0"
480 source = { registry = "https://pypi.org/simple" }
481 dependencies = [
482 { name = "celery" },
483 { name = "django" },
484 ]
485 sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" }
486 wheels = [
487 { url = "https://files.pythonhosted.org/packages/2c/da/70f0f3c5364735344c4bc89e53413bcaae95b4fc1de4e98a7a3b9fb70c88/django_celery_results-2.6.0-py3-none-any.whl", hash = "sha256:b9ccdca2695b98c7cbbb8dea742311ba9a92773d71d7b4944a676e69a7df1c73", size = 38351, upload-time = "2025-04-10T08:23:49.965Z" },
488 ]
489
490 [[package]]
491 name = "django-constance"
492 version = "4.3.5"
493 source = { registry = "https://pypi.org/simple" }
494 sdist = { url = "https://files.pythonhosted.org/packages/9c/95/8eff746544ba8958f431f4cfb162fd632db5f914b05b6a355a98eaf45cfd/django_constance-4.3.5.tar.gz", hash = "sha256:081177483d272b664cf768deae76fc2fbb0a777076f45620b6fde4d9075ee2b3", size = 181943, upload-time = "2026-03-15T11:23:50.799Z" }
495 wheels = [
496 { url = "https://files.pythonhosted.org/packages/61/aa/3ff4198d02c0cb23c595fcfbed364bcf03b80f9109b7b971eaa34604c349/django_constance-4.3.5-py3-none-any.whl", hash = "sha256:c28f360c2822112772a3e4caf02db758c82cca8de7d0b9f648fef371bf4b8bc6", size = 66907, upload-time = "2026-03-15T11:23:49.166Z" },
497 ]
498
499 [[package]]
500 name = "django-cors-headers"
501 version = "4.9.0"
502 source = { registry = "https://pypi.org/simple" }
503 dependencies = [
504 { name = "asgiref" },
505 { name = "django" },
506 ]
507 sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
508 wheels = [
509 { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
510 ]
511
512 [[package]]
513 name = "django-health-check"
514 version = "4.2.1"
515 source = { registry = "https://pypi.org/simple" }
516 dependencies = [
517 { name = "django" },
518 { name = "dnspython" },
519 ]
520 sdist = { url = "https://files.pythonhosted.org/packages/85/fb/fd4f1122c7be1ca28eb9c279b6098432b49565d55041dbe96ebcf815d5c2/django_health_check-4.2.1.tar.gz", hash = "sha256:aa05f57d6b01fe502842273aaa944e988b85d1f58e3ea67b6f98c5f9808a530a", size = 21391, upload-time = "2026-03-20T13:38:01.724Z" }
521 wheels = [
522 { url = "https://files.pythonhosted.org/packages/93/05/d30df07b08194f1d89de44ecba867c467e9cc8e047a4cd7682a994a9468b/django_health_check-4.2.1-py3-none-any.whl", hash = "sha256:7216ba208f82f7587dc0ac0fe4c8f8a1c4d0cebbbae46cff6bd89779b378daf3", size = 26435, upload-time = "2026-03-20T13:38:00.557Z" },
523 ]
524
525 [[package]]
526 name = "django-import-export"
527 version = "4.4.0"
528 source = { registry = "https://pypi.org/simple" }
529 dependencies = [
530 { name = "diff-match-patch" },
531 { name = "django" },
532 { name = "tablib" },
533 ]
534 sdist = { url = "https://files.pythonhosted.org/packages/22/26/279bc8e6cb2c83d1b5dcdca07e932207c3352af11c6d305d6964a2d03ccc/django_import_export-4.4.0.tar.gz", hash = "sha256:9900e99c89027594941074fb4cd63a5f2964975e239021765c0f066003fcd412", size = 2237714, upload-time = "2026-01-10T20:57:35.128Z" }
535 wheels = [
536 { url = "https://files.pythonhosted.org/packages/2f/e0/f4aa6d2374cc6b53b23f36bd0d5814e1db2769b25931b9908723fa295bb0/django_import_export-4.4.0-py3-none-any.whl", hash = "sha256:2d9b234c0f024d3377167f4d9c5a506e095c5bad98e06d30700e1d0752829e3d", size = 157449, upload-time = "2026-01-10T20:57:33.141Z" },
537 ]
538
539 [[package]]
540 name = "django-ratelimit"
541 version = "4.1.0"
542 source = { registry = "https://pypi.org/simple" }
543 sdist = { url = "https://files.pythonhosted.org/packages/6f/8f/94038fe739b095aca3e4708ecc8a4e77f1fcfd87bed5d6baff43d4c80bc4/django-ratelimit-4.1.0.tar.gz", hash = "sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b", size = 11551, upload-time = "2023-07-24T20:34:32.374Z" }
544 wheels = [
545 { url = "https://files.pythonhosted.org/packages/fb/78/2c59b30cd8bc8068d02349acb6aeed5c4e05eb01cdf2107ccd76f2e81487/django_ratelimit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92", size = 11608, upload-time = "2023-07-24T20:34:31.362Z" },
546 ]
547
548 [[package]]
549 name = "django-ses"
550 version = "4.7.2"
551 source = { registry = "https://pypi.org/simple" }
552 dependencies = [
553 { name = "boto3" },
554 { name = "django" },
555 ]
556 sdist = { url = "https://files.pythonhosted.org/packages/7f/25/25838da8e213c9f125b26a25360f0bb8ac57f07c24977451f3e7a0d63ddd/django_ses-4.7.2.tar.gz", hash = "sha256:a36f2af0e4ce060bf36053ed4c94feac1703ea3351e677c6f6421abd01433a35", size = 71828, upload-time = "2026-02-20T19:22:35.078Z" }
557 wheels = [
558 { url = "https://files.pythonhosted.org/packages/84/f2/15d4bd54bd01e68e8a116e0b66243c91cb62a3fa0d780a39d2af6654e8ae/django_ses-4.7.2-py3-none-any.whl", hash = "sha256:f3db567fb6f43c01d7d890f5c991e1ebbfa48220de0be24d497ba6332004abcb", size = 37796, upload-time = "2026-02-20T19:22:32.819Z" },
559 ]
560
561 [[package]]
562 name = "django-simple-history"
563 version = "3.11.0"
564 source = { registry = "https://pypi.org/simple" }
565 dependencies = [
566 { name = "django" },
567 ]
568 sdist = { url = "https://files.pythonhosted.org/packages/a8/11/410049f1454b99a78f719d3403fc89437c2a38ee092e939d5ab8d4846738/django_simple_history-3.11.0.tar.gz", hash = "sha256:2c587479cf2c3071e9aa555d0d11b73676994db4910770958f57659ade2deffe", size = 234862, upload-time = "2025-12-11T13:50:55.022Z" }
569 wheels = [
570 { url = "https://files.pythonhosted.org/packages/6e/c2/e9854a3438cfc80891ab4d3826b7c61a0fe5ba3a4da89104a8f5c9afb5df/django_simple_history-3.11.0-py3-none-any.whl", hash = "sha256:f3c298db49e418ffce7fb709a5e83108452ea2179ec5c4b9232484c25427192a", size = 81868, upload-time = "2025-12-11T13:50:53.71Z" },
571 ]
572
573 [[package]]
574 name = "django-storages"
575 version = "1.14.6"
576 source = { registry = "https://pypi.org/simple" }
577 dependencies = [
578 { name = "django" },
579 ]
580 sdist = { url = "https://files.pythonhosted.org/packages/ff/d6/2e50e378fff0408d558f36c4acffc090f9a641fd6e084af9e54d45307efa/django_storages-1.14.6.tar.gz", hash = "sha256:7a25ce8f4214f69ac9c7ce87e2603887f7ae99326c316bc8d2d75375e09341c9", size = 87587, upload-time = "2025-04-02T02:34:55.103Z" }
581 wheels = [
582 { url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
583 ]
584
585 [package.optional-dependencies]
586 s3 = [
587 { name = "boto3" },
588 ]
589
590 [[package]]
591 name = "django-timezone-field"
592 version = "7.2.1"
593 source = { registry = "https://pypi.org/simple" }
594 dependencies = [
595 { name = "django" },
596 ]
597 sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" }
598 wheels = [
599 { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" },
600 ]
601
602 [[package]]
603 name = "dnspython"
604 version = "2.8.0"
605 source = { registry = "https://pypi.org/simple" }
606 sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
607 wheels = [
608 { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
609 ]
610
611 [[package]]
612 name = "filelock"
613 version = "3.25.2"
614 source = { registry = "https://pypi.org/simple" }
615 sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
616 wheels = [
617 { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
618 ]
619
620 [[package]]
621 name = "fossilrepo"
622 version = "0.1.0"
623 source = { editable = "." }
624 dependencies = [
625 { name = "boto3" },
626 { name = "celery", extra = ["redis"] },
627 { name liard"
628 version = "4.2.4"
629 version = 1
630 revision = 3
631 requires-python = ">=3.12"
632
633 [[package]]
634 name = "amqp"
635 version = "5.3.1"
636 source = { registry = "https://pypi.org/simple" }
637 dependencies = [
638 { name = "vine" },
639 ]
640 sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" }
641 wheels = [
642 { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" },
643 ]
644
645 [[package]]
646 name = "asgiref"
647 version = "3.11.1"
648 source = { registry = "https://pypi.org/simple" }
649 sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4cversio},
650 { name = "django-ses", specifier = ">=4.1" },
651 { name = "django-simple-history", specifier = ">=3.7" },
652 { name = "django-storages", extras = ["s3"], specifier = ">=1.14" },
653 { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" },
654 { name = "gunicorn", specifier = ">=23.0" },
655 { name = "markdown", specifier = ">=3.6" },
656 { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
657 { name = "psycopg2-binary", specifier = ">=2.9" },
658 { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
659 { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
660 { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" },
661 { name = "redis", specifier = ">=5.0" },
662 { name = "requests", specifier = ">=2.31" },
663 { name = "rich", specifier = ">=13.0" },
664 { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
665 { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" },
666 { name = "whitenoise", specifier = ">=6.7" },
667 ]
668 provides-extras = ["dev"]
669
670 [[package]]
671 name = "freezegun"
672 version = "1.5.5"
673 source = { registry = "https://pypi.orich size = 342432, upl8e1c137dfebd5759a2ist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
674 wheels = [
675 { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
676 ]
677
678 [[package]]
679 name = "gunicorn"
680 version = "25.2.0"
681 source = { registry = "https://pypi.org/simple" }
682 dependencies = [
683 { name = "packaging" },
684 ]
685 sdist = { url = "https://files.pythonhosted.org/packages/dd/13/dd3f8e40ea3ee907a6cbf3d1f1f81afcc3ecd0087d313baabfe95372f15c/gunicorn-25.2.0.tar.gz", hash = "sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c", size = 632709, upload-time = "2026-03-24T22:49:54.433Z" }
686 wheels = [
687 { url = "https://files.pythonhosted.org/packages/11/53/fb024445837e02cd5cf989cf349bfac6f3f433c05184ea5d49c8ade751c6/gunicorn-25.2.0-py3-none-any.whl", hash = "sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc", size = 211659, upload-time = "2026-03-24T22:49:52.528Z" },
688 ]
689
690 [[package]]
691 name = "idna"
692 version = "3.11"
693 source = { registry = "https://pypi.org/simple" }
694 sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
695 wheels = [
696 { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b5ichpload-time = "20251e = "2026-01-22T16:35:ruff1.1.0.tar.gz", has0.7 size = 342432, upl8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" }
697 wheels = [
698 { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" },
699 ]
700
701 [package.optional-dependencies]
702 redis = [
703 { name = "redis" },
704 ]
705
706 [[package]]
707 name = "license-expression"
708 version = "30.4.4"
709 source = { registry = "https://pypi.org/simple" }
710 dependencies = [
711 { name = "boolean-py" },
712 ]
713 sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
714 wheels = [
715 { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
716 ]
717
718 [[package]]
719 name = "markdown"
720 version = "3.10.2"
721 source = { registry = "https://pypi.org/simple" }
722 sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" }
723 wheels = [
724 { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" },
725 ]
726
727 [[package]]
728 name = "markdown-it-py"
729 version = "4.0.0"
730 source = { registry = "https://pypi.org/simple" }
731 dependencies = [
732 { name = "mdurl" },
733 ]
734 sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
735 wheels = [
736 { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
737 ]
738
739 [[package]]
740 name = "mdurl"
741 version = "0.1.2"
742 source = { registry = "https://pypi.org/simple" }
743 sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
744 wheels = [
745 { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
746 ]
747
748 [[package]]
749 name = "msgpack"
750 version = "1.1.2"
751 source = { registry = "https://pypi.org/simple" }
752 sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" }
753 wheels = [
754 { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" },
755 { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" },
756 { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" },
757 { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" },
758 { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" },
759 { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" },
760 { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" },
761 { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" },
762 { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" },
763 { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" },
764 { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" },
765 { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" },
766 { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" },
767 { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" },
768 { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" },
769 { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" },
770 { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" },
771 { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" },
772 { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" },
773 { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" },
774 { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" },
775 { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" },
776 { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" },
777 { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" },
778 { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" },
779 { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" },
780 { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" },
781 { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" },
782 { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" },
783 { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" },
784 { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" },
785 { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" },
786 { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" },
787 { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" },
788 { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" },
789 { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
790 ]
791
792 [[package]]
793 name = "packageurl-python"
794 version = "0.17.6"
795 source = { registry = "https://pypi.org/simple" }
796 sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
797 wheels = [
798 { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
799 ]
800
801 [[package]]
802 name = "packaging"
803 version = "26.0"
804 source = { registry = "https://pypi.org/simple" }
805 sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
806 wheels = [
807 { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
808 ]
809
810 [[package]]
811 name = "pip"
812 version = "26.0.1"
813 source = { registry = "https://pypi.org/simple" }
814 sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
815 wheels = [
816 { url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
817 ]
818
819 [[package]]
820 name = "pip-api"
821 version = "0.0.34"
822 source = { registry = "https://pypi.org/simple" }
823 dependencies = [
824 { name = "pip" },
825 ]
826 sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" }
827 wheels = [
828 { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" },
829 ]
830
831 [[package]]
832 name = "pip-audit"
833 version = "2.10.0"
834 source = { registry = "https://pypi.org/simple" }
835 dependencies = [
836 { name = "cachecontrol", extra = ["filecache"] },
837 { name = "cyclonedx-python-lib" },
838 { name = "packaging" },
839 { name = "pip-api" },
840 { name = "pip-requirements-parser" },
841 { name = "platformdirs" },
842 { name = "requests" },
843 { name = "rich" },
844 { name = "tomli" },
845 { name = "tomli-w" },
846 ]
847 sdist = { url = "https://files.pythonhosted.org/packages/bd/89/0e999b413facab81c33d118f3ac3739fd02c0622ccf7c4e82e37cebd8447/pip_audit-2.10.0.tar.gz", hash = "sha256:427ea5bf61d1d06b98b1ae29b7feacc00288a2eced52c9c58ceed5253ef6c2a4", size = 53776, upload-time = "2025-12-01T23:42:40.612Z" }
848 wheels = [
849 { url = "https://files.pythonhosted.org/packages/be/f3/4888f895c02afa085630a3a3329d1b18b998874642ad4c530e9a4d7851fe/pip_audit-2.10.0-py3-none-any.whl", hash = "sha256:16e02093872fac97580303f0848fa3ad64f7ecf600736ea7835a2b24de49613f", size = 61518, upload-time = "2025-12-01T23:42:39.193Z" },
850 ]
851
852 [[package]]
853 name = "pip-requirements-parser"
854 version = "32.0.1"
855 source = { registry = "https://pypi.org/simple" }
856 dependencies = [
857 { name = "packaging" },
858 { name = "pyparsing" },
859 ]
860 sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
861 wheels = [
862 { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
863 ]
864
865 [[package]]
866 name = "platformdirs"
867 version = "4.9.4"
868 source = { registry = "https://pypi.org/simple" }
869 sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
870 wheels = [
871 { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
872 ]
873
874 [[package]]
875 name = "pluggy"
876 version = "1.6.0"
877 source = { registry = "https://pypi.org/simple" }
878 sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
879 wheels = [
880 { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
881 ]
882
883 [[package]]
884 name = "prompt-toolkit"
885 version = "3.0.52"
886 source = { registry = "https://pypi.org/simple" }
887 dependencies = [
888 { name = "wcwidth" },
889 ]
890 sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
891 wheels = [
892 { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
893 ]
894
895 [[package]]
896 name = "psycopg2-binary"
897 version = "2.9.11"
898 source = { registry = "https://pypi.org/simple" }
899 sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
900 wheels = [
901 { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
902 { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
903 { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
904 { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
905 { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
906 { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
907 { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
908 { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
909 { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
910 { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
911 { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
912 { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
913 { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
914 { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
915 { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
916 { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
917 { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
918 { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
919 { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
920 { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
921 { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
922 { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
923 { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
924 { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
925 { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
926 { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
927 { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
928 { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
929 { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
930 { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
931 { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
932 { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
933 { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
934 ]
935
936 [[package]]
937 name = "py-serializable"
938 version = "2.1.0"
939 source = { registry = "https://pypi.org/simple" }
940 dependencies = [
941 { name = "defusedxml" },
942 ]
943 sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
944 wheels = [
945 { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
946 ]
947
948 [[package]]
949 name = "pycparser"
950 version = "3.0"
951 source = { registry = "https://pypi.org/simple" }
952 sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
953 wheels = [
954 { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
955 ]
956
957 [[package]]
958 name = "pygments"
959 version = "2.19.2"
960 source = { registry = "https://pypi.org/simple" }
961 sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
962 wheels = [
963 { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
964 ]
965
966 [[package]]
967 name = "pyparsing"
968 version = "3.3.2"
969 source = { registry = "https://pypi.org/simple" }
970 sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
971 wheels = [
972 { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
973 ]
974
975 [[package]]
976 name = "pytest"
977 version = "9.0.2"
978 source = { registry = "https://pypi.org/simple" }
979 dependencies = [
980 { name = "colorama", marker = "sys_platform == 'win32'" },
981 { name = "iniconfig" },
982 { name = "packaging" },
983 { name = "pluggy" },
984 { name = "pygments" },
985 ]
986 sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
987 wheels = [
988 { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
989 ]
990
991 [[package]]
992 name = "pytest-cov"
993 version = "7.1.0"
994 source = { registry = "https://pypi.org/simple" }
995 dependencies = [
996 { name = "coverage" },
997 { name = "pluggy" },
998 { name = "pytest" },
999 ]
1000 sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
1001 wheels = [
1002 { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
1003 ]
1004
1005 [[package]]
1006 name = "pytest-django"
1007 version = "4.12.0"
1008 source = { registry = "https://pypi.org/simple" }
1009 dependencies = [
1010 { name = "pytest" },
1011 ]
1012 sdist = { url = "https://files.pythonhosted.org/packages/13/2b/db9a193df89e5660137f5428063bcc2ced7ad790003b26974adf5c5ceb3b/pytest_django-4.12.0.tar.gz", hash = "sha256:df94ec819a83c8979c8f6de13d9cdfbe76e8c21d39473cfe2b40c9fc9be3c758", size = 91156, upload-time = "2026-02-14T18:40:49.235Z" }
1013 wheels = [
1014 { url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
1015 ]
1016
1017 [[package]]
1018 name = "python-crontab"
1019 version = "3.3.0"
1020 source = { registry = "https://pypi.org/simple" }
1021 sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" }
1022 wheels = [
1023 { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" },
1024 ]
1025
1026 [[package]]
1027 name = "python-dateutil"
1028 version = "2.9.0.post0"
1029 source = { registry = "https://pypi.org/simple" }
1030 dependencies = [
1031 { name = "six" },
1032 ]
1033 sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
1034 wheels = [
1035 { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
1036 ]
1037
1038 [[package]]
1039 name = "redis"
1040 version = "6.4.0"
1041 source = { registry = "https://pypi.org/simple" }
1042 sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" }
1043 wheels = [
1044 { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
1045 ]
1046
1047 [[package]]
1048 name = "requests"
1049 version = "2.33.0"
1050 source = { registry = "https://pypi.org/simple" }
1051 dependencies = [
1052 { name = "certifi" },
1053 { name = "charset-normalizer" },
1054 { name = "idna" },
1055 { name = "urllib3" },
1056 ]
1057 sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" }
1058 wheels = [
1059 { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" },
1060 ]
1061
1062 [[package]]
1063 name = "rich"
1064 version = "14.3.3"
1065 source = { registry = "https://pypi.org/simple" }
1066 dependencies = [
1067 { name = "markdown-it-py" },
1068 { name = "pygments" },
1069 ]
1070 sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
1071 wheels = [
1072 { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
1073 ]
1074
1075 [[package]]
1076 name = "ruff"
1077 version = "0.15.7"
1078 source = { registry = "https://pypi.org/simple" }
1079 sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" }
1080 wheels = [
1081 { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" },
1082 { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" },
1083 { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" },
1084 { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" },
1085 { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" },
1086 { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" },
1087 { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" },
1088 { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" },
1089 { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" },
1090 { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" },
1091 { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" },
1092 { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" },
1093 { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" },
1094 { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" },
1095 { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" },
1096 { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" },
1097 { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" },
1098 ]
1099
1100 [[package]]
1101 name = "s3transfer"
1102 version = "0.16.0"
1103 source = { registry = "https://pypi.org/simple" }
1104 dependencies = [
1105 { name = "botocore" },
1106 ]
1107 sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
1108 wheels = [
1109 { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
1110 ]
1111
1112 [[package]]
1113 name = "sentry-sdk"
1114 version = "2.56.0"
1115 source = { registry = "https://pypi.org/simple" }
1116 dependencies = [
1117 { name = "certifi" },
1118 { name = "urllib3" },
1119 ]
1120 sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" }
1121 wheels = [
1122 { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" },
1123 ]
1124
1125 [package.optional-dependencies]
1126 django = [
1127 { name = "django" },
1128 ]
1129
1130 [[package]]
1131 name = "six"
1132 version = "1.17.0"
1133 source = { registry = "https://pypi.org/simple" }
1134 sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
1135 wheels = [
1136 { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
1137 ]
1138
1139 [[package]]
1140 name = "sortedcontainers"
1141 version = "2.4.0"
1142 source = { registry = "https://pypi.org/simple" }
1143 sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
1144 wheels = [
1145 { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
1146 ]
1147
1148 [[package]]
1149 name = "sqlparse"
1150 version = "0.5.5"
1151 source = { registry = "https://pypi.org/simple" }
1152 sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }
1153 wheels = [
1154 { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
1155 ]
1156
1157 [[package]]
1158 name = "tablib"
1159 version = "3.9.0"
1160 source = { registry = "https://pypi.org/simple" }
1161 sdist = { url = "https://files.pythonhosted.org/packages/11/00/416d2ba54d7d58a7f7c61bf62dfeb48fd553cf49614daf83312f2d2c156e/tablib-3.9.0.tar.gz", hash = "sha256:1b6abd8edb0f35601e04c6161d79660fdcde4abb4a54f66cc9f9054bd55d5fe2", size = 125565, upload-time = "2025-10-15T18:21:56.263Z" }
1162 wheels = [
1163 { url = "https://files.pythonhosted.org/packages/66/6b/32e51d847148b299088fc42d3d896845fd09c5247190133ea69dbe71ba51/tablib-3.9.0-py3-none-any.whl", hash = "sha256:eda17cd0d4dda614efc0e710227654c60ddbeb1ca92cdcfc5c3bd1fc5f5a6e4a", size = 49580, upload-time = "2025-10-15T18:21:44.185Z" },
1164 ]
1165
1166 [[package]]
1167 name = "tomli"
1168 version = "2.4.1"
1169 source = { registry = "https://pypi.org/simple" }
1170 sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
1171 wheels = [
1172 { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
1173 { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
1174 { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
1175 { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
1176 { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
1177 { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
1178 { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
1179 { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
1180 { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
1181 { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
1182 { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
1183 { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
1184 { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
1185 { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
1186 { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
1187 { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
1188 { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
1189 { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
1190 { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
1191 { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
1192 { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
1193 { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
1194 { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
1195 { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
1196 { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
1197 { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
1198 { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
1199 { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
1200 { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
1201 { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
1202 { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
1203 { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
1204 { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
1205 { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
1206 { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
1207 { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
1208 { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
1209 ]
1210
1211 [[package]]
1212 name = "tomli-w"
1213 version = "1.2.0"
1214 source = { registry = "https://pypi.org/simple" }
1215 sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
1216 wheels = [
1217 { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
1218 ]
1219
1220 [[package]]
1221 name = "typing-extensions"
1222 version = "4.15.0"
1223 source = { registry = "https://pypi.org/simple" }
1224 sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
1225 wheels = [
1226 { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
1227 ]
1228
1229 [[package]]
1230 name = "tzdata"
1231 version = "2025.3"
1232 source = { registry = "https://pypi.org/simple" }
1233 sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
1234 wheels = [
1235 { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
1236 ]
1237
1238 [[package]]
1239 name = "tzlocal"
1240 version = "5.3.1"
1241 source = { registry = "https://pypi.org/simple" }
1242 dependencies = [
1243 { name = "tzdata", marker = "sys_platform == 'win32'" },
1244 ]
1245 sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
1246 wheels = [
1247 { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
1248 ]
1249
1250 [[package]]
1251 name = "urllib3"
1252 version = "2.6.3"
1253 source = { registry = "https://pypi.org/simple" }
1254 sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
1255 wheels = [
1256 { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
1257 ]
1258
1259 [[package]]
1260 name = "vine"
1261 version = "5.1.0"
1262 source = { registry = "https://pypi.org/simple" }
1263 sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" }
1264 wheels = [
1265 { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
1266 ]
1267
1268 [[package]]
1269 name = "wcwidth"
1270 version = "0.6.0"
1271 source = { registry = "https://pypi.org/simple" }
1272 sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
1273 wheels = [
1274 { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
1275 ]
1276
1277 [[package]]
1278 name = "whitenoise"
1279 version = "6.12.0"
1280 source = { registry = "https://pypi.org/simple" }
1281 sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
1282 wheels = [
1283 { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
1284 ]
1285 5.1,<6.0" },
1286 { name = "django-celery-beat", specifier = ">=2.7" },
1287 { name = "django-celery-results", specifier = ">=2.5" },
1288 { name = "django-constance", extras = ["database"], specifier = ">=4.1" },
1289 { name = "django-cors-headers", specifier = ">=4.4" },
1290 { name = "django-health-check", specifier = ">=3.18" },
1291 { name = "django-import-export", specifier = ">=4.0" },
1292 { name = "django-ratelimit", specifier = ">=4.1" },
1293 { name = "django-ses", specifier = ">=4.1" },
1294 { name = "django-simple-history", specifier = ">=3.7" },
1295 { name = "django-storages", extras = ["s3"], specifier = ">=1.14" },
1296 { name = "freezegun", marker = "extra == 'dev'", specifier = ">=1.4" },
1297 { name = "gunicorn", specifier = ">=23.0" },
1298 { name = "markdown", specifier = ">=3.6" },
1299 { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7" },
1300 { name = "psycopg2-binary", specifier = ">=2.9" },
1301 { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" },
1302 { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" },
1303 { name = "pytest-django", marker = "extra == 'dev'", specifier = ">=4.9" },
1304 { name = "redis", specifier = ">=5.0" },
1305 { name = "requests", specifier = ">=2.31" },
1306 { name = "rich", specifier = ">=13.0" },
1307 { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7" },
1308 { name = "sentry-sdk", extras = ["django"], specifier = ">=2.14" },
1309 { name = "whitenoise", specifier = ">=6.7" },
1310 ]
1311 provides-extras = ["dev"]
1312
1313 [[package]]
1314 name = "freezegun"
1315 version = "1.5.5"
1316 source = { registry = "https://pypi.org/simple" }
1317 dependencies = [
1318 { name = "python-dateutil" },
1319 ]
1320 sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
1321 wheels = [
1322 { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
1323 ]
1324
1325 [[package]]
1326 name = "gunicorn"
1327 version = "25.2.0"
1328 source = { registry = "https://pypi.org/simple" }
1329 dependencies = [
1330 { name = "packaging" },
1331 ]
1332 sdist = { url = "https://files.pythonhosted.org/packages/dd/13/dd3f8e40ea3ee907a6cbf3d1f1f81afcc3ecd0087d313baabfe95372f15c/gunicorn-25.2.0.tar.gz", hash = "sha256:10bd7adb36d44945d97d0a1fdf9a0fb086ae9c7b39e56b4dece8555a6bf4a09c", size = 632709, upload-time = "2026-03-24T22:49:54.433Z" }
1333 wheels = [
1334 { url = "https://files.pythonhosted.org/packages/11/53/fb024445837e02cd5cf989cf349bfac6f3f433c05184ea5d49c8ade751c6/gunicorn-25.2.0-py3-none-any.whl", hash = "sha256:88f5b444d0055bf298435384af7294f325e2273fd37ba9f9ff7b98e0a1e5dfdc", size = 211659, upload-time = "2026-03-24T22:49:52.528Z" },
1335 ]
1336
1337 [[package]]
1338 name = "idna"
1339 version = "3.11"
1340 source = { registry = "https://pypi.org/simple" }
1341 sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c0

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button