Compare commits
53 Commits
1.6.0
..
c10b7a8013
| Author | SHA1 | Date | |
|---|---|---|---|
|
c10b7a8013
|
|||
|
103c29e234
|
|||
|
5003b739d3
|
|||
|
4ba3ed555f
|
|||
|
e3b53cd4a9
|
|||
|
a4e697a274
|
|||
|
b8187c32b1
|
|||
|
bf2b86ba1f
|
|||
|
913c7d3a98
|
|||
|
37e3c69abc
|
|||
|
0866eb25e9
|
|||
|
39f21bc7db
|
|||
|
1416d00a37
|
|||
|
d9fe99963a
|
|||
|
393476be85
|
|||
|
e32af2f576
|
|||
|
e565002244
|
|||
|
1a4e51c95a
|
|||
|
eae020fd34
|
|||
|
1f4dd60c54
|
|||
|
656a96f55c
|
|||
|
8c3e819a5f
|
|||
|
ff11e35115
|
|||
|
ebef0bba87
|
|||
|
140f3d2bd6
|
|||
|
245a4f5b3e
|
|||
|
cd9f0b4111
|
|||
|
f82c61ef1e
|
|||
|
4e3b0ddb08
|
|||
|
a549050860
|
|||
|
596d1ccfe1
|
|||
|
bb26fec5e3
|
|||
|
1ba7de0bb7
|
|||
|
3391fb72f2
|
|||
|
0986e59fe7
|
|||
|
46b1199863
|
|||
|
bc1092b0b3
|
|||
|
996c0107c9
|
|||
|
277ecd1b55
|
|||
|
4e3a5ef682
|
|||
|
233f63f18e
|
|||
|
016f307240
|
|||
|
715acd6244
|
|||
|
0bc48d01a7
|
|||
|
c5646d0451
|
|||
|
710a0fc5bc
|
|||
|
1d0d16b4d4
|
|||
|
6b89bab0a6
|
|||
|
2bc2d98f88
|
|||
|
06096d471e
|
|||
|
40869e25f3
|
|||
|
4f0ac21ba3
|
|||
|
3801949fdb
|
@@ -9,28 +9,42 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v4
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.12
|
enable-cache: false
|
||||||
- run: |
|
python-version: "3.14"
|
||||||
python -m pip install poetry
|
|
||||||
poetry install
|
- name: Install dependencies
|
||||||
poetry env info
|
run: uv sync --frozen
|
||||||
poetry run python manage.py migrate
|
|
||||||
# PROD=1 poetry run pytest
|
- name: Run Migrations
|
||||||
|
run: uv run python manage.py migrate
|
||||||
|
|
||||||
|
# - name: Run Tests
|
||||||
|
# run: PROD=1 uv run pytest
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
- uses: docker/build-push-action@v5
|
- name: Set Version
|
||||||
|
run: echo "VERSION_NUMBER=1.6.1" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
registry.kucharczyk.xyz/timetracker:latest
|
registry.kucharczyk.xyz/timetracker:latest
|
||||||
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
|
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
|
||||||
env:
|
# cache-from: type=gha
|
||||||
VERSION_NUMBER: 1.5.1
|
# cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* Add a prompt to set game to Abandoned upon refund
|
||||||
|
|
||||||
|
## 1.6.1 / 2026-01-30 11:48+01:00
|
||||||
|
|
||||||
|
### New
|
||||||
|
* Pre-fill time played into new playevent, also tracks time since last playevent
|
||||||
|
* Improve light theme and fix light/dark theme switcher
|
||||||
|
* Fix purchase form logic
|
||||||
|
* Update dependencies
|
||||||
|
|
||||||
## 1.6.0 / 2025-01-15 23:13+01:00
|
## 1.6.0 / 2025-01-15 23:13+01:00
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
+30
-34
@@ -1,45 +1,41 @@
|
|||||||
FROM python:3.12.0-slim-bullseye
|
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
|
||||||
|
|
||||||
ENV VERSION_NUMBER=1.6.0 \
|
ENV UV_LINK_MODE=copy \
|
||||||
PROD=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /home/timetracker/app
|
||||||
|
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||||
|
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||||
|
uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.14-slim-bookworm
|
||||||
|
|
||||||
|
ENV PROD=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
PYTHONFAULTHANDLER=1 \
|
PATH="/home/timetracker/app/.venv/bin:$PATH"
|
||||||
PYTHONHASHSEED=random \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PIP_NO_CACHE_DIR=1 \
|
|
||||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
|
||||||
PIP_DEFAULT_TIMEOUT=100 \
|
|
||||||
PIP_ROOT_USER_ACTION=ignore \
|
|
||||||
POETRY_NO_INTERACTION=1 \
|
|
||||||
POETRY_VIRTUALENVS_CREATE=false \
|
|
||||||
POETRY_CACHE_DIR='/var/cache/pypoetry' \
|
|
||||||
POETRY_HOME='/usr/local'
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get upgrade -y \
|
|
||||||
&& apt-get install --no-install-recommends -y \
|
|
||||||
bash \
|
|
||||||
curl \
|
|
||||||
&& curl -sSL 'https://install.python-poetry.org' | python - \
|
|
||||||
&& poetry --version \
|
|
||||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
|
||||||
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN useradd -m --uid 1000 timetracker \
|
RUN useradd -m --uid 1000 timetracker \
|
||||||
&& mkdir -p '/var/www/django/static' \
|
&& mkdir -p /var/www/django/static \
|
||||||
&& chown timetracker:timetracker '/var/www/django/static'
|
&& chown timetracker:timetracker /var/www/django/static
|
||||||
|
|
||||||
WORKDIR /home/timetracker/app
|
WORKDIR /home/timetracker/app
|
||||||
COPY . /home/timetracker/app/
|
|
||||||
RUN chown -R timetracker:timetracker /home/timetracker/app
|
|
||||||
COPY entrypoint.sh /
|
|
||||||
RUN chmod +x /entrypoint.sh
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
|
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||||
echo "$PROD" \
|
|
||||||
&& poetry version \
|
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||||
&& poetry run pip install -U pip \
|
RUN chmod +x /entrypoint.sh
|
||||||
&& poetry install --only main --no-interaction --no-ansi --sync
|
|
||||||
|
|
||||||
USER timetracker
|
USER timetracker
|
||||||
|
|
||||||
|
ENV VERSION_NUMBER=1.6.1
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD [ "/entrypoint.sh" ]
|
CMD [ "/entrypoint.sh" ]
|
||||||
|
|||||||
@@ -9,64 +9,71 @@ npm:
|
|||||||
npm install
|
npm install
|
||||||
|
|
||||||
css: common/input.css
|
css: common/input.css
|
||||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
|
npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
|
||||||
|
|
||||||
makemigrations:
|
makemigrations:
|
||||||
poetry run python manage.py makemigrations
|
uv run python manage.py makemigrations
|
||||||
|
|
||||||
migrate: makemigrations
|
migrate: makemigrations
|
||||||
poetry run python manage.py migrate
|
uv run python manage.py migrate
|
||||||
|
|
||||||
init:
|
init:
|
||||||
pyenv install -s $(PYTHON_VERSION)
|
uv install $(PYTHON_VERSION)
|
||||||
pyenv local $(PYTHON_VERSION)
|
uv sync
|
||||||
pip install poetry
|
|
||||||
poetry install
|
|
||||||
npm install
|
npm install
|
||||||
|
$(MAKE) sethookdir
|
||||||
|
$(MAKE) loadplatforms
|
||||||
|
|
||||||
|
sethookdir:
|
||||||
|
git config core.hooksPath .githooks
|
||||||
|
chmod +x .githooks/*
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@npx concurrently \
|
@npx concurrently \
|
||||||
--names "Django,Tailwind" \
|
--names "Django,Tailwind" \
|
||||||
--prefix-colors "blue,green" \
|
--prefix-colors "blue,green" \
|
||||||
"poetry run python -Wa manage.py runserver" \
|
"uv run python -Wa manage.py runserver" \
|
||||||
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
"npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
|
||||||
|
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
caddy run --watch
|
caddy run --watch
|
||||||
|
|
||||||
dev-prod: migrate collectstatic
|
dev-prod: migrate collectstatic
|
||||||
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
|
@npx concurrently \
|
||||||
|
--names "Django,Django-Q" \
|
||||||
|
"PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker"
|
||||||
|
"uv run manage.py qcluster"
|
||||||
|
|
||||||
dumpgames:
|
dumpgames:
|
||||||
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||||
|
|
||||||
loadplatforms:
|
loadplatforms:
|
||||||
poetry run python manage.py loaddata platforms.yaml
|
uv run python manage.py loaddata platforms.yaml
|
||||||
|
|
||||||
loadall:
|
loadall:
|
||||||
poetry run python manage.py loaddata data.yaml
|
uv run python manage.py loaddata data.yaml
|
||||||
|
|
||||||
loadsample:
|
loadsample:
|
||||||
poetry run python manage.py loaddata sample.yaml
|
uv run python manage.py loaddata sample.yaml
|
||||||
|
|
||||||
createsuperuser:
|
createsuperuser:
|
||||||
poetry run python manage.py createsuperuser
|
uv run python manage.py createsuperuser
|
||||||
|
|
||||||
shell:
|
shell:
|
||||||
poetry run python manage.py shell
|
uv run python manage.py shell
|
||||||
|
|
||||||
collectstatic:
|
collectstatic:
|
||||||
poetry run python manage.py collectstatic --clear --no-input
|
uv run python manage.py collectstatic --clear --no-input
|
||||||
|
|
||||||
poetry.lock: pyproject.toml
|
uv.lock: pyproject.toml
|
||||||
poetry install
|
uv sync
|
||||||
|
|
||||||
test: poetry.lock
|
test: uv.lock
|
||||||
poetry run pytest
|
uv run --with pytest-django pytest
|
||||||
|
|
||||||
date:
|
date:
|
||||||
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||||
|
|
||||||
cleanstatic:
|
cleanstatic:
|
||||||
rm -r static/*
|
rm -r static/*
|
||||||
|
|||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
# Game & Purchase Status Definitions
|
||||||
|
|
||||||
|
## Game Statuses
|
||||||
|
|
||||||
|
Games have a `status` field with the following values:
|
||||||
|
|
||||||
|
| Status | Code | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| **Unplayed** | `u` | Game was purchased but never played |
|
||||||
|
| **Played** | `p` | Game was played but not yet finished |
|
||||||
|
| **Finished** | `f` | Game has been completed |
|
||||||
|
| **Retired** | `r` | Game was intentionally retired (e.g., no longer accessible, collector's item) |
|
||||||
|
| **Abandoned** | `a` | Game was played but the user gave up on it |
|
||||||
|
|
||||||
|
**Setting game status:**
|
||||||
|
- Users explicitly set game status via the UI (finish/drop purchase buttons, status change form)
|
||||||
|
- Status changes are tracked in `GameStatusChange` model
|
||||||
|
- Refunding a purchase always marks its games as abandoned
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purchase-Level Status Concepts
|
||||||
|
|
||||||
|
These concepts determine whether a purchase appears in the "unfinished" or "dropped" lists in stats views.
|
||||||
|
|
||||||
|
### Finished
|
||||||
|
|
||||||
|
A purchase is considered **finished** when:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game.status == "f" OR Purchase.games.* has a PlayEvent with an ended date
|
||||||
|
```
|
||||||
|
|
||||||
|
Either signal indicates the game is complete:
|
||||||
|
- **Explicit**: User marked the game as finished (`Game.status = "f"`)
|
||||||
|
- **Implicit**: A PlayEvent exists with `ended` date set (data-driven)
|
||||||
|
|
||||||
|
This uses **OR** logic during a transition period. Later, these signals should be kept in sync so only one source of truth is needed.
|
||||||
|
|
||||||
|
### Dropped
|
||||||
|
|
||||||
|
A purchase is considered **dropped** when:
|
||||||
|
|
||||||
|
```
|
||||||
|
Game.status == "a" OR Purchase.date_refunded IS NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
Either signal indicates the user no longer has an active interest in the game:
|
||||||
|
- **Explicit**: User marked the game as abandoned (`Game.status = "a"`)
|
||||||
|
- **Implicit**: User refunded the purchase (which automatically sets games to abandoned)
|
||||||
|
|
||||||
|
Note: Refunding a purchase always marks its games as abandoned. There is no option to refund without abandoning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unfinished vs. Dropped
|
||||||
|
|
||||||
|
The stats views categorize purchases into **unfinished** and **dropped** lists.
|
||||||
|
|
||||||
|
### Unfinished
|
||||||
|
|
||||||
|
A purchase is **unfinished** when:
|
||||||
|
1. It was purchased in the relevant time period (this year for yearly stats, all time for all-time stats)
|
||||||
|
2. It was NOT refunded (only counts toward unfinished/backlog)
|
||||||
|
3. It is NOT finished (per the finished definition above)
|
||||||
|
4. It is NOT dropped (per the dropped definition above)
|
||||||
|
5. It is NOT infinite (subscription, etc.)
|
||||||
|
6. It IS a game or DLC (not season passes or battle passes)
|
||||||
|
|
||||||
|
**Unfinished = Active backlog** — games the user may still play.
|
||||||
|
|
||||||
|
### Dropped
|
||||||
|
|
||||||
|
A purchase is **dropped** when:
|
||||||
|
1. It was purchased in the relevant time period
|
||||||
|
2. It is NOT finished (per the finished definition above)
|
||||||
|
3. It matches at least one dropped signal (per the dropped definition above)
|
||||||
|
4. It is NOT infinite
|
||||||
|
5. It IS a game or DLC
|
||||||
|
|
||||||
|
**Dropped = Terminal state** — games the user has given up on or refunded.
|
||||||
|
|
||||||
|
### Summary Table
|
||||||
|
|
||||||
|
| Category | Includes Refunded? | Key Condition |
|
||||||
|
|----------|-------------------|---------------|
|
||||||
|
| **Unfinished** | No | NOT finished, NOT dropped |
|
||||||
|
| **Dropped** | Yes | Finished OR Abandoned/Retired |
|
||||||
|
| **Refunded** | Yes | `date_refunded IS NOT NULL` |
|
||||||
|
| **Infinite** | Yes | `infinite = True` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Query Patterns
|
||||||
|
|
||||||
|
### Checking if a game is finished
|
||||||
|
|
||||||
|
```python
|
||||||
|
game.finished() # Returns True if status="f" or has PlayEvent with ended date
|
||||||
|
```
|
||||||
|
|
||||||
|
### Checking if a game is abandoned
|
||||||
|
|
||||||
|
```python
|
||||||
|
game.abandoned() # Returns True if status="a"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting finished purchases
|
||||||
|
|
||||||
|
```python
|
||||||
|
Purchase.objects.finished() # All purchases where games are finished
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting dropped purchases
|
||||||
|
|
||||||
|
```python
|
||||||
|
Purchase.objects.dropped() # All purchases that are abandoned or refunded
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transition State
|
||||||
|
|
||||||
|
The system uses **OR logic** for both finished and dropped to catch any mismatch between explicit user actions and data signals:
|
||||||
|
|
||||||
|
- **Finished**: `status="f" OR PlayEvent.ended`
|
||||||
|
- **Dropped**: `status="a" OR date_refunded`
|
||||||
|
|
||||||
|
This bridges the gap between the old model (where `date_finished` and `date_dropped` were on the Purchase model) and the new model (where `Game.status` and `PlayEvent` are the sources of truth).
|
||||||
|
|
||||||
|
**Future:** These signals should be kept in sync. For example:
|
||||||
|
- Setting `Game.status = "f"` should create a PlayEvent with `ended` date
|
||||||
|
- When the sync is reliable, the OR can be simplified to a single check
|
||||||
|
|
||||||
|
Note: Refunding a purchase always automatically sets its games' status to Abandoned. This is not optional — there is no way to refund without abandoning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### Unplayed games
|
||||||
|
- Unplayed games (`status="u"`) are considered **unfinished**, not dropped
|
||||||
|
- They appear in the unfinished/backlog list since they are still games the user may play
|
||||||
|
- Unplayed games that are refunded DO count as **dropped** (refund signal overrides)
|
||||||
|
|
||||||
|
### Multiple games per purchase
|
||||||
|
- A purchase can have multiple games via `Purchase.games` (many-to-many)
|
||||||
|
- A purchase is finished if ANY of its games is finished
|
||||||
|
- A purchase is dropped if ANY of its games is abandoned OR the purchase itself is refunded
|
||||||
|
|
||||||
|
### PlayEvents without ended date
|
||||||
|
- A PlayEvent with `started` but no `ended` does NOT count as finished
|
||||||
|
- This represents a game that was started but not completed
|
||||||
|
|
||||||
|
### Retired games
|
||||||
|
- Retired games (`status="r"`) are considered **dropped**
|
||||||
|
- Retirement is for games the user intentionally removed from their collection (collector's items, no longer accessible, etc.)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Suggested Improvements to common/components.py
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
### Caching on template rendering
|
||||||
|
- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
|
||||||
|
- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
|
||||||
|
- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
|
||||||
|
- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
|
||||||
|
- Verified working: identical calls return identical output, different inputs produce separate cache entries
|
||||||
|
|
||||||
|
### Non-deterministic IDs
|
||||||
|
`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
|
||||||
|
- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
|
||||||
|
- `games/templatetags/randomid.py` uses the same hash-based approach
|
||||||
|
- Fixes: caching (Popover output now cacheable), page consistency, thread safety
|
||||||
|
|
||||||
|
### Inconsistent return types
|
||||||
|
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
|
||||||
|
|
||||||
|
### Fragile A() URL resolution
|
||||||
|
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
|
||||||
|
|
||||||
|
### Toast XSS vulnerability
|
||||||
|
The vulnerable `Toast()` component (which used unsafe string escaping for
|
||||||
|
Alpine.js interpolation) had no callers and was deleted entirely. Toast display
|
||||||
|
is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
|
||||||
|
headers → `show-toast` CustomEvent → Alpine store.
|
||||||
|
|
||||||
|
### Default mutable arguments
|
||||||
|
All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
|
||||||
|
|
||||||
|
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
|
||||||
|
|
||||||
|
### NameWithIcon dead code and untestable design
|
||||||
|
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
|
||||||
|
|
||||||
|
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
|
||||||
|
|
||||||
|
### No tests
|
||||||
|
Zero test coverage for the entire component system.
|
||||||
|
|
||||||
|
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
|
||||||
|
and cache hit/miss verification.
|
||||||
|
|
||||||
|
**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
|
||||||
+138
-81
@@ -1,11 +1,13 @@
|
|||||||
from random import choices as random_choices
|
import hashlib
|
||||||
from string import ascii_lowercase
|
import json
|
||||||
from typing import Any, Callable
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.template import TemplateDoesNotExist
|
from django.template import TemplateDoesNotExist
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import NoReverseMatch, reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.utils import truncate
|
from common.utils import truncate
|
||||||
@@ -15,12 +17,32 @@ HTMLAttribute = tuple[str, str | int | bool]
|
|||||||
HTMLTag = str
|
HTMLTag = str
|
||||||
|
|
||||||
|
|
||||||
|
def _render_cached_impl(template: str, context_json: str) -> str:
|
||||||
|
context = json.loads(context_json)
|
||||||
|
context["slot"] = mark_safe(context["slot"])
|
||||||
|
return render_to_string(template, context)
|
||||||
|
|
||||||
|
|
||||||
|
if not settings.DEBUG:
|
||||||
|
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
||||||
|
else:
|
||||||
|
_render_cached = _render_cached_impl
|
||||||
|
|
||||||
|
|
||||||
|
def enable_cache():
|
||||||
|
"""Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
|
||||||
|
global _render_cached
|
||||||
|
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
||||||
|
|
||||||
|
|
||||||
def Component(
|
def Component(
|
||||||
attributes: list[HTMLAttribute] = [],
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: list[HTMLTag] | HTMLTag = [],
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
template: str = "",
|
template: str = "",
|
||||||
tag_name: str = "",
|
tag_name: str = "",
|
||||||
) -> HTMLTag:
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
if not tag_name and not template:
|
if not tag_name and not template:
|
||||||
raise ValueError("One of template or tag_name is required.")
|
raise ValueError("One of template or tag_name is required.")
|
||||||
if isinstance(children, str):
|
if isinstance(children, str):
|
||||||
@@ -37,28 +59,32 @@ def Component(
|
|||||||
if tag_name != "":
|
if tag_name != "":
|
||||||
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
|
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
|
||||||
elif template != "":
|
elif template != "":
|
||||||
tag = render_to_string(
|
context = {name: value for name, value in attributes} | {"slot": "\n".join(children)}
|
||||||
template,
|
tag = _render_cached(template, json.dumps(context, sort_keys=True))
|
||||||
{name: value for name, value in attributes}
|
|
||||||
| {"slot": mark_safe("\n".join(children))},
|
|
||||||
)
|
|
||||||
return mark_safe(tag)
|
return mark_safe(tag)
|
||||||
|
|
||||||
|
|
||||||
def randomid(seed: str = "", length: int = 10) -> str:
|
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||||
return seed + "".join(random_choices(ascii_lowercase, k=length))
|
if not seed and not content:
|
||||||
|
return seed
|
||||||
|
hash_input = f"{seed}:{content}" if seed else content
|
||||||
|
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||||
|
base = content_hash[:length] if not seed else content_hash[:max(0, length - len(seed))]
|
||||||
|
return seed + base
|
||||||
|
|
||||||
|
|
||||||
def Popover(
|
def Popover(
|
||||||
popover_content: str,
|
popover_content: str,
|
||||||
wrapped_content: str = "",
|
wrapped_content: str = "",
|
||||||
wrapped_classes: str = "",
|
wrapped_classes: str = "",
|
||||||
children: list[HTMLTag] = [],
|
children: list[HTMLTag] | None = None,
|
||||||
attributes: list[HTMLAttribute] = [],
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
if not wrapped_content and not children:
|
if not wrapped_content and not children:
|
||||||
raise ValueError("One of wrapped_content or children is required.")
|
raise ValueError("One of wrapped_content or children is required.")
|
||||||
id = randomid()
|
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||||
return Component(
|
return Component(
|
||||||
attributes=attributes
|
attributes=attributes
|
||||||
+ [
|
+ [
|
||||||
@@ -105,60 +131,71 @@ def PopoverTruncated(
|
|||||||
|
|
||||||
|
|
||||||
def A(
|
def A(
|
||||||
attributes: list[HTMLAttribute] = [],
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: list[HTMLTag] | HTMLTag = [],
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
url: str | Callable[..., Any] = "",
|
url_name: str | None = None,
|
||||||
):
|
href: str | None = None,
|
||||||
|
) -> SafeText:
|
||||||
"""
|
"""
|
||||||
Returns the HTML tag "a".
|
Returns an anchor <a> tag.
|
||||||
"url" can either be:
|
|
||||||
- URL (string)
|
Accepts one of two mutually-exclusive URL specifications:
|
||||||
- path name passed to reverse() (string)
|
- url_name: URL pattern name, resolved via reverse()
|
||||||
- function
|
- href: Literal path string passed through as-is
|
||||||
"""
|
"""
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
if url_name is not None and href is not None:
|
||||||
|
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||||
|
|
||||||
additional_attributes = []
|
additional_attributes = []
|
||||||
if url:
|
if url_name is not None:
|
||||||
if type(url) is str:
|
additional_attributes = [("href", reverse(url_name))]
|
||||||
try:
|
elif href is not None:
|
||||||
url_result = reverse(url)
|
additional_attributes = [("href", href)]
|
||||||
except NoReverseMatch:
|
|
||||||
url_result = url
|
|
||||||
elif callable(url):
|
|
||||||
url_result = url()
|
|
||||||
else:
|
|
||||||
raise TypeError("'url' is neither str nor function.")
|
|
||||||
additional_attributes = [("href", url_result)]
|
|
||||||
return Component(
|
return Component(
|
||||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def Button(
|
def Button(
|
||||||
attributes: list[HTMLAttribute] = [],
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: list[HTMLTag] | HTMLTag = [],
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
size: str = "base",
|
size: str = "base",
|
||||||
icon: bool = False,
|
icon: bool = False,
|
||||||
color: str = "blue",
|
color: str = "blue",
|
||||||
):
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
return Component(
|
return Component(
|
||||||
template="cotton/button.html",
|
template="cotton/button.html",
|
||||||
attributes=attributes + [("size", size), ("icon", icon), ("color", color)],
|
attributes=attributes
|
||||||
|
+ [
|
||||||
|
("size", size),
|
||||||
|
("icon", icon),
|
||||||
|
("color", color),
|
||||||
|
("class", "hover:cursor-pointer"),
|
||||||
|
],
|
||||||
children=children,
|
children=children,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def Div(
|
def Div(
|
||||||
attributes: list[HTMLAttribute] = [],
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: list[HTMLTag] | HTMLTag = [],
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
):
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
return Component(tag_name="div", attributes=attributes, children=children)
|
return Component(tag_name="div", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
def Input(
|
def Input(
|
||||||
type: str = "text",
|
type: str = "text",
|
||||||
attributes: list[HTMLAttribute] = [],
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: list[HTMLTag] | HTMLTag = [],
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
):
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
return Component(
|
return Component(
|
||||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||||
)
|
)
|
||||||
@@ -167,9 +204,11 @@ def Input(
|
|||||||
def Form(
|
def Form(
|
||||||
action="",
|
action="",
|
||||||
method="get",
|
method="get",
|
||||||
attributes: list[HTMLAttribute] = [],
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: list[HTMLTag] | HTMLTag = [],
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
):
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
return Component(
|
return Component(
|
||||||
tag_name="form",
|
tag_name="form",
|
||||||
attributes=attributes + [("action", action), ("method", method)],
|
attributes=attributes + [("action", action), ("method", method)],
|
||||||
@@ -179,8 +218,9 @@ def Form(
|
|||||||
|
|
||||||
def Icon(
|
def Icon(
|
||||||
name: str,
|
name: str,
|
||||||
attributes: list[HTMLAttribute] = [],
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
):
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
try:
|
try:
|
||||||
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
|
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
|
||||||
except TemplateDoesNotExist:
|
except TemplateDoesNotExist:
|
||||||
@@ -189,7 +229,7 @@ def Icon(
|
|||||||
|
|
||||||
|
|
||||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||||
link = reverse("view_purchase", args=[int(purchase.id)])
|
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||||
link_content = ""
|
link_content = ""
|
||||||
popover_content = ""
|
popover_content = ""
|
||||||
game_count = purchase.games.count()
|
game_count = purchase.games.count()
|
||||||
@@ -226,35 +266,20 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return mark_safe(A(url=link, children=[a_content]))
|
return A(href=link, children=[a_content])
|
||||||
|
|
||||||
|
|
||||||
def NameWithIcon(
|
def NameWithIcon(
|
||||||
name: str = "",
|
name: str = "",
|
||||||
platform: str = "",
|
game: Game | None = None,
|
||||||
game_id: int = 0,
|
session: Session | None = None,
|
||||||
session_id: int = 0,
|
|
||||||
purchase_id: int = 0,
|
|
||||||
linkify: bool = True,
|
linkify: bool = True,
|
||||||
emulated: bool = False,
|
emulated: bool = False,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
create_link = False
|
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||||
link = ""
|
name, game, session, linkify
|
||||||
platform = None
|
)
|
||||||
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
|
|
||||||
create_link = True
|
|
||||||
if session_id:
|
|
||||||
session = Session.objects.get(pk=session_id)
|
|
||||||
emulated = session.emulated
|
|
||||||
game_id = session.game.pk
|
|
||||||
if purchase_id:
|
|
||||||
purchase = Purchase.objects.get(pk=purchase_id)
|
|
||||||
game_id = purchase.games.first().pk
|
|
||||||
if game_id:
|
|
||||||
game = Game.objects.get(pk=game_id)
|
|
||||||
name = name or game.name
|
|
||||||
platform = game.platform
|
|
||||||
link = reverse("view_game", args=[int(game_id)])
|
|
||||||
content = Div(
|
content = Div(
|
||||||
[("class", "inline-flex gap-2 items-center")],
|
[("class", "inline-flex gap-2 items-center")],
|
||||||
[
|
[
|
||||||
@@ -264,24 +289,56 @@ def NameWithIcon(
|
|||||||
)
|
)
|
||||||
if platform
|
if platform
|
||||||
else "",
|
else "",
|
||||||
Icon("emulated", [("title", "Emulated")]) if emulated else "",
|
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||||
PopoverTruncated(name),
|
PopoverTruncated(_name),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
return mark_safe(
|
return (
|
||||||
A(
|
A(
|
||||||
url=link,
|
href=link,
|
||||||
children=[content],
|
children=[content],
|
||||||
)
|
)
|
||||||
if create_link
|
if create_link
|
||||||
else content,
|
else content
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def PurchasePrice(purchase) -> str:
|
def _resolve_name_with_icon(
|
||||||
|
name: str,
|
||||||
|
game: Game | None,
|
||||||
|
session: Session | None,
|
||||||
|
linkify: bool,
|
||||||
|
) -> tuple[str, Any, bool, bool, str]:
|
||||||
|
create_link = False
|
||||||
|
link = ""
|
||||||
|
platform = None
|
||||||
|
final_emulated = False
|
||||||
|
|
||||||
|
if session is not None:
|
||||||
|
game = session.game
|
||||||
|
platform = game.platform
|
||||||
|
final_emulated = session.emulated
|
||||||
|
if linkify:
|
||||||
|
create_link = True
|
||||||
|
link = reverse("games:view_game", args=[int(game.pk)])
|
||||||
|
elif game is not None:
|
||||||
|
platform = game.platform
|
||||||
|
if linkify:
|
||||||
|
create_link = True
|
||||||
|
link = reverse("games:view_game", args=[int(game.pk)])
|
||||||
|
|
||||||
|
_name = name or (game.name if game else "")
|
||||||
|
|
||||||
|
return _name, platform, final_emulated, create_link, link
|
||||||
|
|
||||||
|
|
||||||
|
def PurchasePrice(purchase) -> SafeText:
|
||||||
return Popover(
|
return Popover(
|
||||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||||
wrapped_classes="underline decoration-dotted",
|
wrapped_classes="underline decoration-dotted",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+153
-115
@@ -1,127 +1,143 @@
|
|||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@font-face {
|
@plugin '@tailwindcss/typography';
|
||||||
font-family: "IBM Plex Mono";
|
@plugin '@tailwindcss/forms';
|
||||||
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2");
|
@plugin 'flowbite/plugin';
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
@source "../node_modules/flowbite";
|
||||||
font-family: "IBM Plex Sans";
|
@import "flowbite/src/themes/default";
|
||||||
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
@custom-variant dark (&:is(.dark *));
|
||||||
font-family: "IBM Plex Serif";
|
|
||||||
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
@theme {
|
||||||
font-family: "IBM Plex Serif";
|
--font-sans:
|
||||||
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
|
IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||||
font-weight: 700;
|
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
font-style: normal;
|
--font-mono:
|
||||||
}
|
IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||||
|
'Liberation Mono', 'Courier New', monospace;
|
||||||
|
--font-serif:
|
||||||
|
IBM Plex Serif, ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||||
|
--font-condensed:
|
||||||
|
IBM Plex Sans Condensed, ui-sans-serif, system-ui, sans-serif,
|
||||||
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
|
||||||
@font-face {
|
--color-accent: #7c3aed;
|
||||||
font-family: "IBM Plex Sans Condensed";
|
--color-background: #1f2937;
|
||||||
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* a:hover {
|
/*
|
||||||
text-decoration-color: #ff4400;
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
color: rgb(254, 185, 160);
|
so we've added these compatibility styles to make sure everything still
|
||||||
transition: all 0.2s ease-out;
|
looks the same as it did with Tailwind CSS v3.
|
||||||
} */
|
|
||||||
|
|
||||||
/* form label {
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
@apply dark:text-slate-400;
|
color utility to any element that depends on these defaults.
|
||||||
} */
|
*/
|
||||||
|
@layer base {
|
||||||
.responsive-table {
|
*,
|
||||||
@apply dark:text-white mx-auto table-fixed;
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table tr:nth-child(even) {
|
@utility min-w-20char {
|
||||||
@apply bg-slate-800
|
min-width: 20ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table tbody tr:nth-child(odd) {
|
@utility max-w-20char {
|
||||||
@apply bg-slate-900
|
max-width: 20ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table thead th {
|
@utility min-w-30char {
|
||||||
@apply text-left border-b-2 border-b-slate-500 text-xl;
|
min-width: 30ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.responsive-table thead th:not(:first-child),
|
@utility max-w-30char {
|
||||||
.responsive-table td:not(:first-child) {
|
max-width: 30ch;
|
||||||
@apply border-l border-l-slate-500;
|
}
|
||||||
|
|
||||||
|
@utility max-w-35char {
|
||||||
|
max-width: 35ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility max-w-40char {
|
||||||
|
max-width: 40ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.min-w-20char {
|
@font-face {
|
||||||
min-width: 20ch;
|
font-family: 'IBM Plex Mono';
|
||||||
|
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
.max-w-20char {
|
|
||||||
max-width: 20ch;
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Sans';
|
||||||
|
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
.min-w-30char {
|
|
||||||
min-width: 30ch;
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Serif';
|
||||||
|
src: url('fonts/IBMPlexSerif-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
.max-w-30char {
|
|
||||||
max-width: 30ch;
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Serif';
|
||||||
|
src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
.max-w-35char {
|
|
||||||
max-width: 35ch;
|
@font-face {
|
||||||
|
font-family: 'IBM Plex Sans Condensed';
|
||||||
|
src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
.max-w-40char {
|
|
||||||
max-width: 40ch;
|
.responsive-table {
|
||||||
|
@apply dark:text-white mx-auto table-fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table tr:nth-child(even) {
|
||||||
|
@apply bg-indigo-100 dark:bg-slate-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table tbody tr:nth-child(odd) {
|
||||||
|
@apply bg-indigo-200 dark:bg-slate-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table thead th {
|
||||||
|
@apply text-left border-b-2 border-b-slate-500 text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.responsive-table thead th:not(:first-child),
|
||||||
|
.responsive-table td:not(:first-child) {
|
||||||
|
@apply border-l border-l-slate-500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* form input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
|
||||||
} */
|
|
||||||
|
|
||||||
form input:disabled,
|
form input:disabled,
|
||||||
select:disabled,
|
select:disabled,
|
||||||
textarea:disabled {
|
textarea:disabled {
|
||||||
@apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed;
|
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.errorlist {
|
.errorlist {
|
||||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
|
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* @media screen and (min-width: 768px) {
|
|
||||||
form input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
|
|
||||||
/* @media screen and (max-width: 768px) {
|
|
||||||
form input,
|
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
width: 150px;
|
|
||||||
}
|
|
||||||
} */
|
|
||||||
|
|
||||||
#button-container button {
|
#button-container button {
|
||||||
@apply mx-1;
|
@apply mx-1;
|
||||||
}
|
}
|
||||||
@@ -131,7 +147,7 @@ textarea:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.basic-button {
|
.basic-button {
|
||||||
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
|
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded-sm shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-hidden focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown-content ul {
|
.markdown-content ul {
|
||||||
@@ -162,34 +178,56 @@ textarea:disabled {
|
|||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .truncate-container {
|
#add-form {
|
||||||
@apply inline-block relative;
|
label + select, input, textarea {
|
||||||
a {
|
@apply mt-1;
|
||||||
@apply inline-block truncate max-w-20char transition-all group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4;
|
}
|
||||||
|
form {
|
||||||
}
|
@apply flex flex-col gap-3;
|
||||||
} */
|
}
|
||||||
|
|
||||||
label {
|
.form-row-button-group {
|
||||||
@apply dark:text-slate-500;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
@apply gap-0 p-0;
|
||||||
|
button {
|
||||||
|
@apply mr-0;
|
||||||
|
&:first-child {
|
||||||
|
@apply rounded-e-none;
|
||||||
|
}
|
||||||
|
&:nth-child(2) {
|
||||||
|
@apply rounded-none;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
@apply rounded-s-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
@apply mb-2.5 text-sm font-medium text-heading;
|
||||||
|
}
|
||||||
|
input:not([type="checkbox"]) {
|
||||||
|
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||||
|
}
|
||||||
|
input[type="checkbox"] {
|
||||||
|
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
|
||||||
|
}
|
||||||
|
:has(> label + input[type="checkbox"]) {
|
||||||
|
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[type="text"], [type="password"], [type="datetime-local"], [type="datetime"], [type="date"], [type="number"], select, textarea {
|
@layer utilities {
|
||||||
@apply dark:bg-slate-600 dark:text-slate-300;
|
.toast-container {
|
||||||
}
|
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||||
|
}
|
||||||
[type="submit"] {
|
|
||||||
@apply dark:text-white font-bold dark:bg-blue-600 px-4 py-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
form div label {
|
|
||||||
@apply dark:text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
form div {
|
|
||||||
@apply flex flex-col;
|
|
||||||
}
|
|
||||||
|
|
||||||
div [type="submit"] {
|
|
||||||
@apply mt-3;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -2,10 +2,10 @@
|
|||||||
# Apply database migrations
|
# Apply database migrations
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
echo "Apply database migrations"
|
echo "Apply database migrations"
|
||||||
poetry run python manage.py migrate
|
python manage.py migrate
|
||||||
|
|
||||||
echo "Collect static files"
|
echo "Collect static files"
|
||||||
poetry run python manage.py collectstatic --clear --no-input
|
python manage.py collectstatic --clear --no-input
|
||||||
|
|
||||||
_term() {
|
_term() {
|
||||||
echo "Caught SIGTERM signal!"
|
echo "Caught SIGTERM signal!"
|
||||||
@@ -15,9 +15,9 @@ _term() {
|
|||||||
trap _term SIGTERM
|
trap _term SIGTERM
|
||||||
|
|
||||||
echo "Starting Django-Q cluster"
|
echo "Starting Django-Q cluster"
|
||||||
poetry run python manage.py qcluster & django_q_pid=$!
|
python manage.py qcluster & django_q_pid=$!
|
||||||
|
|
||||||
echo "Starting app"
|
echo "Starting app"
|
||||||
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
|
python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
|
||||||
|
|
||||||
wait "$gunicorn_pid" "$django_q_pid"
|
wait "$gunicorn_pid" "$django_q_pid"
|
||||||
|
|||||||
+25
-4
@@ -1,11 +1,12 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.timezone import now as django_timezone_now
|
from django.utils.timezone import now as django_timezone_now
|
||||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema
|
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
|
||||||
|
|
||||||
from games.models import Game, PlayEvent
|
from games.models import Game, PlayEvent, Session
|
||||||
|
|
||||||
api = NinjaAPI()
|
api = NinjaAPI()
|
||||||
playevent_router = Router()
|
playevent_router = Router()
|
||||||
@@ -54,7 +55,8 @@ def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
|
|||||||
game = get_object_or_404(Game, id=game_id)
|
game = get_object_or_404(Game, id=game_id)
|
||||||
setattr(game, "status", payload.status)
|
setattr(game, "status", payload.status)
|
||||||
game.save()
|
game.save()
|
||||||
return 204, None
|
messages.success(request, "Status updated")
|
||||||
|
return Status(204, None)
|
||||||
|
|
||||||
|
|
||||||
@playevent_router.get("/", response=List[PlayEventOut])
|
@playevent_router.get("/", response=List[PlayEventOut])
|
||||||
@@ -65,6 +67,7 @@ def list_playevents(request):
|
|||||||
@playevent_router.post("/", response={201: PlayEventOut})
|
@playevent_router.post("/", response={201: PlayEventOut})
|
||||||
def create_playevent(request, payload: PlayEventIn):
|
def create_playevent(request, payload: PlayEventIn):
|
||||||
playevent = PlayEvent.objects.create(**payload.dict())
|
playevent = PlayEvent.objects.create(**payload.dict())
|
||||||
|
messages.success(request, "Game played!")
|
||||||
return playevent
|
return playevent
|
||||||
|
|
||||||
|
|
||||||
@@ -87,9 +90,27 @@ def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEven
|
|||||||
def delete_playevent(request, playevent_id: int):
|
def delete_playevent(request, playevent_id: int):
|
||||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||||
playevent.delete()
|
playevent.delete()
|
||||||
return 204, None
|
return Status(204, None)
|
||||||
|
|
||||||
|
|
||||||
api.add_router("/playevent", playevent_router)
|
api.add_router("/playevent", playevent_router)
|
||||||
api.add_router("/games", game_router)
|
api.add_router("/games", game_router)
|
||||||
|
|
||||||
|
session_router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionDeviceUpdate(Schema):
|
||||||
|
device_id: int
|
||||||
|
|
||||||
|
|
||||||
|
@session_router.patch("/{session_id}/device", response={204: None})
|
||||||
|
def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate):
|
||||||
|
session = get_object_or_404(Session, id=session_id)
|
||||||
|
session.device_id = payload.device_id
|
||||||
|
session.save()
|
||||||
|
messages.success(request, "Device updated")
|
||||||
|
return Status(204, None)
|
||||||
|
|
||||||
|
|
||||||
|
api.add_router("/session", session_router)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -95,7 +95,7 @@ class PurchaseForm(forms.ModelForm):
|
|||||||
|
|
||||||
# Automatically update related_purchase <select/>
|
# Automatically update related_purchase <select/>
|
||||||
# to only include purchases of the selected game.
|
# to only include purchases of the selected game.
|
||||||
related_purchase_by_game_url = reverse("related_purchase_by_game")
|
related_purchase_by_game_url = reverse("games:related_purchase_by_game")
|
||||||
self.fields["games"].widget.attrs.update(
|
self.fields["games"].widget.attrs.update(
|
||||||
{
|
{
|
||||||
"hx-trigger": "load, click",
|
"hx-trigger": "load, click",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
from .game import Mutation as GameMutation
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import graphene
|
|
||||||
|
|
||||||
from games.graphql.types import Game
|
|
||||||
from games.models import Game as GameModel
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateGameMutation(graphene.Mutation):
|
|
||||||
class Arguments:
|
|
||||||
id = graphene.ID(required=True)
|
|
||||||
name = graphene.String()
|
|
||||||
year_released = graphene.Int()
|
|
||||||
wikidata = graphene.String()
|
|
||||||
|
|
||||||
game = graphene.Field(Game)
|
|
||||||
|
|
||||||
def mutate(self, info, id, name=None, year_released=None, wikidata=None):
|
|
||||||
game_instance = GameModel.objects.get(pk=id)
|
|
||||||
if name is not None:
|
|
||||||
game_instance.name = name
|
|
||||||
if year_released is not None:
|
|
||||||
game_instance.year_released = year_released
|
|
||||||
if wikidata is not None:
|
|
||||||
game_instance.wikidata = wikidata
|
|
||||||
game_instance.save()
|
|
||||||
return UpdateGameMutation(game=game_instance)
|
|
||||||
|
|
||||||
|
|
||||||
class Mutation(graphene.ObjectType):
|
|
||||||
update_game = UpdateGameMutation.Field()
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from .device import Query as DeviceQuery
|
|
||||||
from .game import Query as GameQuery
|
|
||||||
from .platform import Query as PlatformQuery
|
|
||||||
from .purchase import Query as PurchaseQuery
|
|
||||||
from .session import Query as SessionQuery
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import graphene
|
|
||||||
|
|
||||||
from games.graphql.types import Device
|
|
||||||
from games.models import Device as DeviceModel
|
|
||||||
|
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
|
||||||
devices = graphene.List(Device)
|
|
||||||
|
|
||||||
def resolve_devices(self, info, **kwargs):
|
|
||||||
return DeviceModel.objects.all()
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import graphene
|
|
||||||
|
|
||||||
from games.graphql.types import Game
|
|
||||||
from games.models import Game as GameModel
|
|
||||||
|
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
|
||||||
games = graphene.List(Game)
|
|
||||||
game_by_name = graphene.Field(Game, name=graphene.String(required=True))
|
|
||||||
|
|
||||||
def resolve_games(self, info, **kwargs):
|
|
||||||
return GameModel.objects.all()
|
|
||||||
|
|
||||||
def resolve_game_by_name(self, info, name):
|
|
||||||
try:
|
|
||||||
return GameModel.objects.get(name=name)
|
|
||||||
except GameModel.DoesNotExist:
|
|
||||||
return None
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import graphene
|
|
||||||
|
|
||||||
from games.graphql.types import Platform
|
|
||||||
from games.models import Platform as PlatformModel
|
|
||||||
|
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
|
||||||
platforms = graphene.List(Platform)
|
|
||||||
|
|
||||||
def resolve_platforms(self, info, **kwargs):
|
|
||||||
return PlatformModel.objects.all()
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import graphene
|
|
||||||
|
|
||||||
from games.graphql.types import Purchase
|
|
||||||
from games.models import Purchase as PurchaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
|
||||||
purchases = graphene.List(Purchase)
|
|
||||||
|
|
||||||
def resolve_purchases(self, info, **kwargs):
|
|
||||||
return PurchaseModel.objects.all()
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import graphene
|
|
||||||
|
|
||||||
from games.graphql.types import Session
|
|
||||||
from games.models import Session as SessionModel
|
|
||||||
|
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
|
||||||
sessions = graphene.List(Session)
|
|
||||||
|
|
||||||
def resolve_sessions(self, info, **kwargs):
|
|
||||||
return SessionModel.objects.all()
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
from graphene_django import DjangoObjectType
|
|
||||||
|
|
||||||
from games.models import Device as DeviceModel
|
|
||||||
from games.models import Edition as EditionModel
|
|
||||||
from games.models import Game as GameModel
|
|
||||||
from games.models import Platform as PlatformModel
|
|
||||||
from games.models import Purchase as PurchaseModel
|
|
||||||
from games.models import Session as SessionModel
|
|
||||||
|
|
||||||
|
|
||||||
class Game(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = GameModel
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class Edition(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = EditionModel
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class Purchase(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = PurchaseModel
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class Session(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = SessionModel
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class Platform(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = PlatformModel
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class Device(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = DeviceModel
|
|
||||||
fields = "__all__"
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages as django_messages
|
||||||
|
from django.contrib.messages import constants as message_constants
|
||||||
|
|
||||||
|
MESSAGE_LEVEL_MAP = {
|
||||||
|
message_constants.DEBUG: "debug",
|
||||||
|
message_constants.INFO: "info",
|
||||||
|
message_constants.SUCCESS: "success",
|
||||||
|
message_constants.WARNING: "warning",
|
||||||
|
message_constants.ERROR: "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXMessagesMiddleware:
|
||||||
|
"""
|
||||||
|
Converts Django messages into HX-Trigger headers so toasts display
|
||||||
|
automatically without changes to views.
|
||||||
|
|
||||||
|
Works for HTMX requests (processed natively by HTMX client),
|
||||||
|
vanilla fetch() calls using fetchWithHtmxTriggers(), and is harmless
|
||||||
|
for full-page loads (browsers ignore HX-Trigger).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
# Skip HX-Trigger and don't consume messages if there's an HX-Redirect
|
||||||
|
# so the message persists in the session for the redirect target page
|
||||||
|
if "HX-Redirect" in response:
|
||||||
|
return response
|
||||||
|
|
||||||
|
min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||||
|
backend = django_messages.get_messages(request)
|
||||||
|
if hasattr(backend, '_set_level') and backend._get_level() > min_level:
|
||||||
|
backend._set_level(min_level)
|
||||||
|
messages = list(backend)
|
||||||
|
if not messages:
|
||||||
|
return response
|
||||||
|
|
||||||
|
triggers = []
|
||||||
|
for msg in messages:
|
||||||
|
toast_type = MESSAGE_LEVEL_MAP.get(msg.level, "info")
|
||||||
|
triggers.append(
|
||||||
|
{
|
||||||
|
"message": msg.message,
|
||||||
|
"type": toast_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if triggers:
|
||||||
|
# Use last message (most recent) as the primary toast
|
||||||
|
trigger = triggers[-1]
|
||||||
|
response["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"show-toast": trigger,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-12 11:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('games', '0015_alter_purchase_date_purchased_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='purchase',
|
||||||
|
name='needs_price_update',
|
||||||
|
field=models.BooleanField(db_index=True, default=True),
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
"UPDATE games_purchase SET needs_price_update = FALSE WHERE converted_price IS NOT NULL AND converted_currency != ''",
|
||||||
|
reverse_sql="UPDATE games_purchase SET needs_price_update = TRUE WHERE converted_price IS NOT NULL AND converted_currency != ''",
|
||||||
|
),
|
||||||
|
]
|
||||||
+20
-20
@@ -4,7 +4,7 @@ from datetime import timedelta
|
|||||||
import requests
|
import requests
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F, Sum
|
from django.db.models import F, Q, Sum
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
from django.db.models.fields.generated import GeneratedField
|
from django.db.models.fields.generated import GeneratedField
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
@@ -66,7 +66,8 @@ class Game(models.Model):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def finished(self):
|
def finished(self):
|
||||||
return self.status == self.Status.FINISHED
|
return (self.status == self.Status.FINISHED or
|
||||||
|
self.playevents.filter(ended__isnull=False).exists())
|
||||||
|
|
||||||
def abandoned(self):
|
def abandoned(self):
|
||||||
return self.status == self.Status.ABANDONED
|
return self.status == self.Status.ABANDONED
|
||||||
@@ -120,6 +121,19 @@ class PurchaseQueryset(models.QuerySet):
|
|||||||
def games_only(self):
|
def games_only(self):
|
||||||
return self.filter(type=Purchase.GAME)
|
return self.filter(type=Purchase.GAME)
|
||||||
|
|
||||||
|
def finished(self):
|
||||||
|
return self.filter(
|
||||||
|
Q(games__status="f") | Q(games__playevents__ended__isnull=False)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
def abandoned(self):
|
||||||
|
return self.filter(games__status="a").distinct()
|
||||||
|
|
||||||
|
def dropped(self):
|
||||||
|
return self.filter(
|
||||||
|
Q(games__status="a") | Q(date_refunded__isnull=False)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
|
||||||
class Purchase(models.Model):
|
class Purchase(models.Model):
|
||||||
PHYSICAL = "ph"
|
PHYSICAL = "ph"
|
||||||
@@ -165,6 +179,7 @@ class Purchase(models.Model):
|
|||||||
price_currency = models.CharField(max_length=3, default="USD")
|
price_currency = models.CharField(max_length=3, default="USD")
|
||||||
converted_price = models.FloatField(null=True)
|
converted_price = models.FloatField(null=True)
|
||||||
converted_currency = models.CharField(max_length=3, blank=True, default="")
|
converted_currency = models.CharField(max_length=3, blank=True, default="")
|
||||||
|
needs_price_update = models.BooleanField(default=True, db_index=True)
|
||||||
price_per_game = GeneratedField(
|
price_per_game = GeneratedField(
|
||||||
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
|
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
|
||||||
output_field=models.FloatField(),
|
output_field=models.FloatField(),
|
||||||
@@ -226,30 +241,15 @@ class Purchase(models.Model):
|
|||||||
def is_game(self):
|
def is_game(self):
|
||||||
return self.type == self.GAME
|
return self.type == self.GAME
|
||||||
|
|
||||||
def price_or_currency_differ_from(self, purchase_to_compare):
|
def refund(self):
|
||||||
return (
|
self.date_refunded = timezone.now()
|
||||||
self.price != purchase_to_compare.price
|
self.save()
|
||||||
or self.price_currency != purchase_to_compare.price_currency
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.type != Purchase.GAME and not self.related_purchase:
|
if self.type != Purchase.GAME and not self.related_purchase:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"{self.get_type_display()} must have a related purchase."
|
f"{self.get_type_display()} must have a related purchase."
|
||||||
)
|
)
|
||||||
if self.pk is not None:
|
|
||||||
# Retrieve the existing instance from the database
|
|
||||||
existing_purchase = Purchase.objects.get(pk=self.pk)
|
|
||||||
# If price has changed, reset converted fields
|
|
||||||
if existing_purchase.price_or_currency_differ_from(self):
|
|
||||||
from games.tasks import currency_to
|
|
||||||
|
|
||||||
exchange_rate = get_or_create_rate(
|
|
||||||
self.price_currency, currency_to, self.date_purchased.year
|
|
||||||
)
|
|
||||||
if exchange_rate:
|
|
||||||
self.converted_price = floatformat(self.price * exchange_rate, 0)
|
|
||||||
self.converted_currency = currency_to
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import graphene
|
|
||||||
|
|
||||||
from games.graphql.mutations import GameMutation
|
|
||||||
from games.graphql.queries import (
|
|
||||||
DeviceQuery,
|
|
||||||
EditionQuery,
|
|
||||||
GameQuery,
|
|
||||||
PlatformQuery,
|
|
||||||
PurchaseQuery,
|
|
||||||
SessionQuery,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Query(
|
|
||||||
GameQuery,
|
|
||||||
EditionQuery,
|
|
||||||
DeviceQuery,
|
|
||||||
PlatformQuery,
|
|
||||||
PurchaseQuery,
|
|
||||||
SessionQuery,
|
|
||||||
graphene.ObjectType,
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Mutation(GameMutation, graphene.ObjectType):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
schema = graphene.Schema(query=Query, mutation=Mutation)
|
|
||||||
@@ -17,6 +17,29 @@ from games.models import Game, GameStatusChange, Purchase, Session
|
|||||||
logger = logging.getLogger("games")
|
logger = logging.getLogger("games")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_save, sender=Purchase)
|
||||||
|
def store_purchase_price_snapshot(sender, instance, **kwargs):
|
||||||
|
"""Store old price values before save so we can detect changes."""
|
||||||
|
if instance.pk is not None:
|
||||||
|
try:
|
||||||
|
old_instance = sender.objects.get(pk=instance.pk)
|
||||||
|
instance._old_price = old_instance.price
|
||||||
|
instance._old_currency = old_instance.price_currency
|
||||||
|
except sender.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Purchase)
|
||||||
|
def mark_needs_price_update(sender, instance, created, **kwargs):
|
||||||
|
"""Mark purchase for price update if price or currency changed."""
|
||||||
|
if not created and hasattr(instance, "_old_price"):
|
||||||
|
if (
|
||||||
|
instance.price != instance._old_price
|
||||||
|
or instance.price_currency != instance._old_currency
|
||||||
|
):
|
||||||
|
sender.objects.filter(pk=instance.pk).update(needs_price_update=True)
|
||||||
|
|
||||||
|
|
||||||
@receiver(m2m_changed, sender=Purchase.games.through)
|
@receiver(m2m_changed, sender=Purchase.games.through)
|
||||||
def update_num_purchases(sender, instance, action, reverse, **kwargs):
|
def update_num_purchases(sender, instance, action, reverse, **kwargs):
|
||||||
if not reverse and action.startswith("post_"):
|
if not reverse and action.startswith("post_"):
|
||||||
|
|||||||
+5115
-3388
File diff suppressed because it is too large
Load Diff
@@ -25,18 +25,7 @@ function setupElementHandlers() {
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||||
getEl("#id_type").onchange = () => {
|
getEl("#id_type").addEventListener("change", () => {
|
||||||
setupElementHandlers();
|
setupElementHandlers();
|
||||||
};
|
}
|
||||||
|
);
|
||||||
document.body.addEventListener("htmx:beforeRequest", function (event) {
|
|
||||||
// Assuming 'Purchase1' is the element that triggers the HTMX request
|
|
||||||
if (event.target.id === "id_games") {
|
|
||||||
var idEditionValue = document.getElementById("id_games").value;
|
|
||||||
|
|
||||||
// Condition to check - replace this with your actual logic
|
|
||||||
if (idEditionValue != "") {
|
|
||||||
event.preventDefault(); // This cancels the HTMX request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
(function() {
|
||||||
|
htmx.defineExtension("hx-redirect-toast", {
|
||||||
|
isInlineSwap: function(swapStyle) {
|
||||||
|
return swapStyle === "hx-redirect-toast";
|
||||||
|
},
|
||||||
|
handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) {
|
||||||
|
var xhr = htmxConfig.xhr;
|
||||||
|
var hxRedirect = xhr.getResponseHeader("HX-Redirect");
|
||||||
|
var hxTrigger = xhr.getResponseHeader("HX-Trigger");
|
||||||
|
|
||||||
|
// Redirect immediately (toast will be shown on the new page)
|
||||||
|
if (hxRedirect) {
|
||||||
|
window.location.href = hxRedirect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only dispatch HX-Trigger events for toasts when not redirecting
|
||||||
|
if (!hxRedirect && hxTrigger) {
|
||||||
|
var triggers = JSON.parse(hxTrigger);
|
||||||
|
var events = Array.isArray(triggers) ? triggers : [triggers];
|
||||||
|
events.forEach(function(triggerObj) {
|
||||||
|
Object.entries(triggerObj).forEach(function(entry) {
|
||||||
|
var name = entry[0];
|
||||||
|
var detail = entry[1];
|
||||||
|
try { detail = JSON.parse(detail); } catch(e) {}
|
||||||
|
target.dispatchEvent(new CustomEvent(name, {
|
||||||
|
detail: detail,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Return null to prevent any DOM swap
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,173 @@
|
|||||||
|
document.addEventListener("alpine:init", () => {
|
||||||
|
let idCounter = 0;
|
||||||
|
|
||||||
|
console.log("[toast] Alpine available:", typeof Alpine !== "undefined");
|
||||||
|
|
||||||
|
Alpine.store("toasts", {
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
addToast(message, type) {
|
||||||
|
console.log("[toast] addToast called:", { message, type });
|
||||||
|
if (!type) type = "info";
|
||||||
|
const validTypes = ["success", "error", "info", "warning", "debug"];
|
||||||
|
if (!validTypes.includes(type)) type = "info";
|
||||||
|
|
||||||
|
if (this.toasts.length >= 3) {
|
||||||
|
console.log("[toast] max 3 toasts reached, removing oldest");
|
||||||
|
this.toasts.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ++idCounter;
|
||||||
|
console.log("[toast] toast added, count:", this.toasts.length);
|
||||||
|
this.toasts.push({ id, message, type, visible: true, timer: null, pausedAt: null });
|
||||||
|
|
||||||
|
if (type !== "error") {
|
||||||
|
const toast = this.toasts[this.toasts.length - 1];
|
||||||
|
const autoDismissDelay = type === "debug" ? 3000 : 5000;
|
||||||
|
toast.timer = setTimeout(() => {
|
||||||
|
console.log("[toast] auto-dismiss after " + (autoDismissDelay / 1000) + "s");
|
||||||
|
this.dismissToast(id);
|
||||||
|
}, autoDismissDelay);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissToast(id) {
|
||||||
|
console.log("[toast] dismissToast for id:", id);
|
||||||
|
const idx = this.toasts.findIndex((t) => t.id === id);
|
||||||
|
if (idx === -1) { console.log("[toast] toast not found"); return; }
|
||||||
|
|
||||||
|
const toast = this.toasts[idx];
|
||||||
|
if (toast.timer) clearTimeout(toast.timer);
|
||||||
|
toast.visible = false;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.toasts = this.toasts.filter((t) => t.id !== id);
|
||||||
|
console.log("[toast] after dismiss, count:", this.toasts.length);
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearToastTimer(id) {
|
||||||
|
const toast = this.toasts.find((t) => t.id === id);
|
||||||
|
if (toast?.timer) {
|
||||||
|
console.log("[toast] pause timer for toast id:", id);
|
||||||
|
clearTimeout(toast.timer);
|
||||||
|
toast.timer = null;
|
||||||
|
toast.pausedAt = Date.now();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resumeToastTimer(id, duration) {
|
||||||
|
const toast = this.toasts.find((t) => t.id === id);
|
||||||
|
if (toast?.pausedAt && toast.timer === null) {
|
||||||
|
console.log("[toast] resume timer for toast id:", id);
|
||||||
|
toast.timer = setTimeout(() => {
|
||||||
|
this.dismissToast(id);
|
||||||
|
}, duration);
|
||||||
|
toast.pausedAt = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Alpine.data("toastStore", () => ({
|
||||||
|
init() {
|
||||||
|
console.log("[toast] toastStore.init running");
|
||||||
|
console.log("[toast] Alpine store toasts:", Alpine.store("toasts").toasts);
|
||||||
|
|
||||||
|
window.addEventListener("show-toast", (e) => {
|
||||||
|
console.log("[toast] show-toast event received:", e.detail);
|
||||||
|
if (Array.isArray(e.detail)) {
|
||||||
|
e.detail.forEach((msg) => {
|
||||||
|
Alpine.store("toasts").addToast(msg.message, msg.type);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Alpine.store("toasts").addToast(e.detail.message, e.detail.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const script = document.getElementById("django-messages");
|
||||||
|
if (script) {
|
||||||
|
const msgs = JSON.parse(
|
||||||
|
script.textContent || script.innerText || "[]"
|
||||||
|
);
|
||||||
|
console.log("[toast] django-messages script found:", msgs);
|
||||||
|
if (Array.isArray(msgs)) {
|
||||||
|
msgs.forEach((msg) => {
|
||||||
|
console.log("[toast] loading django-message:", msg);
|
||||||
|
Alpine.store("toasts").addToast(msg.message, msg.type || "info");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[toast] localStorage restore failed:", e);
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addToast(message, type) {
|
||||||
|
console.log("[toast] toastStore.addToast delegating:", { message, type });
|
||||||
|
Alpine.store("toasts").addToast(message, type);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissToast(id) {
|
||||||
|
console.log("[toast] toastStore.dismissToast delegating:", id);
|
||||||
|
Alpine.store("toasts").dismissToast(id);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function toast(message, type) {
|
||||||
|
console.log("[toast] toast() called:", { message, type });
|
||||||
|
const evt = new CustomEvent("show-toast", {
|
||||||
|
detail: { message, type },
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(evt);
|
||||||
|
console.log("[toast] CustomEvent dispatched, type:", evt.type);
|
||||||
|
}
|
||||||
|
window.toast = toast;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around fetch() that dispatches HTMX HX-Trigger events.
|
||||||
|
* Use this for any fetch() call that expects HX-Trigger headers
|
||||||
|
* (e.g., to show toasts via the HTMX middleware).
|
||||||
|
*
|
||||||
|
* @todo Migrate these call sites to hx-post + hx-on::after-request
|
||||||
|
* for HTMX-native toast handling.
|
||||||
|
*/
|
||||||
|
window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(url, options = {}) {
|
||||||
|
console.log("[fetchWithHtmxTriggers] fetching:", url);
|
||||||
|
return fetch(url, options).then(async (response) => {
|
||||||
|
console.log("[fetchWithHtmxTriggers] response status:", response.status);
|
||||||
|
const htmxTrigger = response.headers.get("HX-Trigger");
|
||||||
|
console.log("[fetchWithHtmxTriggers] HX-Trigger header:", htmxTrigger);
|
||||||
|
if (htmxTrigger) {
|
||||||
|
let triggers;
|
||||||
|
try {
|
||||||
|
triggers = JSON.parse(htmxTrigger);
|
||||||
|
console.log("[fetchWithHtmxTriggers] parsed triggers:", triggers);
|
||||||
|
} catch {
|
||||||
|
console.warn("[fetchWithHtmxTriggers] failed to parse HX-Trigger JSON");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
// Handle both single object and array of events
|
||||||
|
const events = Array.isArray(triggers) ? triggers : [triggers];
|
||||||
|
events.forEach((triggerObj) => {
|
||||||
|
Object.entries(triggerObj).forEach(([name, detail]) => {
|
||||||
|
console.log("[fetchWithHtmxTriggers] dispatching event:", name, detail);
|
||||||
|
let parsedDetail = detail;
|
||||||
|
try {
|
||||||
|
parsedDetail = JSON.parse(detail);
|
||||||
|
} catch {
|
||||||
|
// keep as string
|
||||||
|
}
|
||||||
|
document.dispatchEvent(new CustomEvent(name, {
|
||||||
|
detail: parsedDetail,
|
||||||
|
bubbles: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -43,6 +43,7 @@ function syncSelectInputUntilChanged(syncData, parentSelector = document) {
|
|||||||
const targetElement = document.querySelector(syncItem.target);
|
const targetElement = document.querySelector(syncItem.target);
|
||||||
|
|
||||||
if (targetElement && valueToSync !== null) {
|
if (targetElement && valueToSync !== null) {
|
||||||
|
console.log(`Changing value of ${syncItem.target} to ${valueToSync}`)
|
||||||
targetElement[syncItem.target_value] = valueToSync;
|
targetElement[syncItem.target_value] = valueToSync;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,13 +185,17 @@ function disableElementsWhenValueNotEqual(
|
|||||||
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||||
return conditionalElementHandler([
|
return conditionalElementHandler([
|
||||||
() => {
|
() => {
|
||||||
|
console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`)
|
||||||
|
console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`)
|
||||||
return getEl(targetSelect).value == targetValue;
|
return getEl(targetSelect).value == targetValue;
|
||||||
},
|
},
|
||||||
elementList,
|
elementList,
|
||||||
(el) => {
|
(el) => {
|
||||||
|
console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`)
|
||||||
el.disabled = "disabled";
|
el.disabled = "disabled";
|
||||||
},
|
},
|
||||||
(el) => {
|
(el) => {
|
||||||
|
console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`)
|
||||||
el.disabled = "";
|
el.disabled = "";
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
+69
-46
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from django.db import models
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
|
|
||||||
logger = logging.getLogger("games")
|
logger = logging.getLogger("games")
|
||||||
@@ -12,66 +13,88 @@ currency_to = "CZK"
|
|||||||
currency_to = currency_to.upper()
|
currency_to = currency_to.upper()
|
||||||
|
|
||||||
|
|
||||||
def save_converted_info(purchase, converted_price, converted_currency):
|
def _get_exchange_rate(currency_from, currency_to, year):
|
||||||
|
logger.debug(
|
||||||
|
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
|
||||||
|
)
|
||||||
|
rate = ExchangeRate.objects.filter(
|
||||||
|
currency_from=currency_from, currency_to=currency_to, year=year
|
||||||
|
).first()
|
||||||
|
if not rate:
|
||||||
|
logger.debug(
|
||||||
|
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = requests.get(
|
||||||
|
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
currency_from_data = data.get(currency_from.lower())
|
||||||
|
rate = currency_from_data.get(currency_to.lower())
|
||||||
|
if rate:
|
||||||
|
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
||||||
|
exchange_rate = ExchangeRate.objects.create(
|
||||||
|
currency_from=currency_from,
|
||||||
|
currency_to=currency_to,
|
||||||
|
year=year,
|
||||||
|
rate=floatformat(rate, 2),
|
||||||
|
)
|
||||||
|
rate = exchange_rate.rate
|
||||||
|
else:
|
||||||
|
logger.info("[convert_prices]: Could not get an exchange rate.")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.info(
|
||||||
|
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
||||||
|
)
|
||||||
|
elif rate:
|
||||||
|
rate = rate.rate
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _save_converted_price(purchase, converted_price, needs_update):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_currency})"
|
f"Setting converted price of {purchase} to {converted_price} {currency_to} (originally {purchase.price} {purchase.price_currency})"
|
||||||
)
|
)
|
||||||
purchase.converted_price = converted_price
|
purchase.converted_price = converted_price
|
||||||
purchase.converted_currency = converted_currency
|
purchase.converted_currency = currency_to
|
||||||
purchase.save()
|
if needs_update:
|
||||||
|
purchase.needs_price_update = False
|
||||||
|
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
|
||||||
|
|
||||||
|
|
||||||
def convert_prices():
|
def convert_prices():
|
||||||
purchases = Purchase.objects.filter(
|
purchases = Purchase.objects.filter(
|
||||||
converted_price__isnull=True, converted_currency=""
|
models.Q(needs_price_update=True) | models.Q(converted_price__isnull=True)
|
||||||
)
|
).distinct()
|
||||||
if purchases.count() == 0:
|
if purchases.count() == 0:
|
||||||
logger.info("[convert_prices]: No prices to convert.")
|
logger.info("[convert_prices]: No prices to convert.")
|
||||||
|
return
|
||||||
|
|
||||||
for purchase in purchases:
|
for purchase in purchases:
|
||||||
|
needs_update = purchase.needs_price_update
|
||||||
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
|
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
|
||||||
save_converted_info(purchase, purchase.price, currency_to)
|
_save_converted_price(purchase, purchase.price, needs_update)
|
||||||
continue
|
continue
|
||||||
year = purchase.date_purchased.year
|
year = purchase.date_purchased.year
|
||||||
currency_from = purchase.price_currency.upper()
|
currency_from = purchase.price_currency.upper()
|
||||||
|
rate = _get_exchange_rate(currency_from, currency_to, year)
|
||||||
exchange_rate = ExchangeRate.objects.filter(
|
if rate:
|
||||||
currency_from=currency_from, currency_to=currency_to, year=year
|
_save_converted_price(
|
||||||
).first()
|
|
||||||
logger.info(
|
|
||||||
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
|
|
||||||
)
|
|
||||||
if not exchange_rate:
|
|
||||||
logger.info(
|
|
||||||
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# this API endpoint only accepts lowercase currency string
|
|
||||||
response = requests.get(
|
|
||||||
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
currency_from_data = data.get(currency_from.lower())
|
|
||||||
rate = currency_from_data.get(currency_to.lower())
|
|
||||||
|
|
||||||
if rate:
|
|
||||||
logger.info(f"[convert_prices]: Got {rate}, saving...")
|
|
||||||
exchange_rate = ExchangeRate.objects.create(
|
|
||||||
currency_from=currency_from,
|
|
||||||
currency_to=currency_to,
|
|
||||||
year=year,
|
|
||||||
rate=floatformat(rate, 2),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.info("[convert_prices]: Could not get an exchange rate.")
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.info(
|
|
||||||
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
|
|
||||||
)
|
|
||||||
if exchange_rate:
|
|
||||||
save_converted_info(
|
|
||||||
purchase,
|
purchase,
|
||||||
floatformat(purchase.price * exchange_rate.rate, 0),
|
floatformat(purchase.price * rate, 0),
|
||||||
currency_to,
|
needs_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_price_per_game():
|
||||||
|
"""
|
||||||
|
This task is deprecated because price_per_game is now a GeneratedField.
|
||||||
|
It is kept here to prevent errors from lingering scheduled tasks.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from django_q.models import Schedule
|
||||||
|
|
||||||
|
Schedule.objects.filter(func="games.tasks.calculate_price_per_game").delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<c-layouts.add>
|
<c-layouts.add>
|
||||||
<c-slot name="additional_row">
|
<c-slot name="additional_row">
|
||||||
<input type="submit"
|
<c-button type="submit" color="gray"
|
||||||
name="submit_and_redirect"
|
name="submit_and_redirect"
|
||||||
value="Submit & Create Purchase" />
|
>
|
||||||
|
Submit & Create Purchase
|
||||||
|
</c-button>
|
||||||
</c-slot>
|
</c-slot>
|
||||||
</c-layouts.add>
|
</c-layouts.add>
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>
|
<td>
|
||||||
<input type="submit"
|
<c-button type="submit"
|
||||||
|
color="gray"
|
||||||
name="submit_and_redirect"
|
name="submit_and_redirect"
|
||||||
value="Submit & Create Session" />
|
>
|
||||||
|
Submit & Create Session
|
||||||
|
</c-button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</c-slot>
|
</c-slot>
|
||||||
|
|||||||
@@ -1,36 +1,38 @@
|
|||||||
<c-layouts.add>
|
<c-layouts.add>
|
||||||
<c-slot name="form_content">
|
<c-slot name="form_content">
|
||||||
<form method="post" enctype="multipart/form-data">
|
<div class="max-width-container">
|
||||||
<table class="mx-auto">
|
<div id="add-form" class="form-container max-w-xl mx-auto">
|
||||||
{% csrf_token %}
|
<form method="post" enctype="multipart/form-data" class="">
|
||||||
{% for field in form %}
|
{% csrf_token %}
|
||||||
<tr>
|
{% for field in form %}
|
||||||
<th>{{ field.label_tag }}</th>
|
<div>
|
||||||
|
{{ field.label_tag }}
|
||||||
{% if field.name == "note" %}
|
{% if field.name == "note" %}
|
||||||
<td>{{ field }}</td>
|
{{ field }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<td>{{ field }}</td>
|
{{ field }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
||||||
<td>
|
<span class="form-row-button-group flex-row gap-3 justify-start mt-3" hx-boost="false">
|
||||||
<div class="basic-button-container" hx-boost="false">
|
<c-button data-target="{{ field.name }}" data-type="now" size="xs">Set to now</c-button>
|
||||||
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
|
<c-button data-target="{{ field.name }}" data-type="toggle" size="xs">Toggle text</c-button>
|
||||||
<button class="basic-button"
|
<c-button data-target="{{ field.name }}" data-type="copy" size="xs">
|
||||||
data-target="{{ field.name }}"
|
Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %}
|
||||||
data-type="toggle">Toggle text</button>
|
</c-button>
|
||||||
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
|
</span>
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<tr>
|
<div>
|
||||||
<td></td>
|
<c-button type="submit">
|
||||||
<td>
|
Submit
|
||||||
<input type="submit" value="Submit" />
|
</c-button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
<div class="submit-button-container">
|
||||||
</table>
|
{{ additional_row }}
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</c-slot>
|
</c-slot>
|
||||||
</c-layouts.add>
|
</c-layouts.add>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
<c-vars color="blue" size="base" type="button" />
|
<c-vars color="blue" size="base" type="button" />
|
||||||
<button type="{{ type }}"
|
<button
|
||||||
title="{{ title }}"
|
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||||
class="{{ class }} {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
|
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||||
|
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||||
|
{% if type %}type="{{ type }}"{% endif %}
|
||||||
|
{% if title %}title="{{ title }}"{% endif %}
|
||||||
|
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||||
|
{% if data_target %}data-target="{{ data_target }}"{% endif %}
|
||||||
|
{% if data_type %}data-type="{{ data_type }}"{% endif %}
|
||||||
|
{% if name %}name="{{ name }}"{% endif %}
|
||||||
|
class="{% if class %}{{ class }} {%else%}{%endif%}{% if color == "blue" %}text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} leading-5 focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-base {% if size == "xs" %} px-3 py-2 text-xs shadow-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<div class="inline-flex rounded-md shadow-sm" role="group">
|
<div class="inline-flex rounded-md shadow-xs" role="group">
|
||||||
{% if slot %}{{ slot }}{% endif %}
|
{% if slot %}{{ slot }}{% endif %}
|
||||||
{% for button in buttons %}
|
{% for button in buttons %}
|
||||||
{% if button.slot %}
|
{% if button.slot %}
|
||||||
<c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title />
|
<c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title :hx_get=button.hx_get :hx_target=button.hx_target :hx_swap=button.hx_swap />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
<c-vars color="gray" />
|
<c-vars color="gray" />
|
||||||
<a href="{{ href }}"
|
<a href="{{ href }}"
|
||||||
|
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||||
|
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||||
|
{% if click %}@click="{{ click }}"{% endif %}
|
||||||
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
|
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
|
||||||
{% if color == "gray" %}
|
{% if color == "gray" %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
title="{{ title }}"
|
title="{{ title }}"
|
||||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</button>
|
</button>
|
||||||
{% elif color == "red" %}
|
{% elif color == "red" %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
title="{{ title }}"
|
title="{{ title }}"
|
||||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</button>
|
</button>
|
||||||
{% elif color == "green" %}
|
{% elif color == "green" %}
|
||||||
<button type="button"
|
<button type="button"
|
||||||
title="{{ title }}"
|
title="{{ title }}"
|
||||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white">
|
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white hover:cursor-pointer">
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ text
|
|||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<a href="{{ link }}"
|
<a href="{{ link }}"
|
||||||
title="{{ title }}"
|
title="{{ title }}"
|
||||||
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm">
|
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-xs">
|
||||||
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
|
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ text
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
title="{{ title }}"
|
title="{{ title }}"
|
||||||
autofocus
|
autofocus
|
||||||
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg">
|
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-lg">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<span class="truncate-container">
|
<span class="truncate-container">
|
||||||
<a class="underline decoration-slate-500 sm:decoration-2"
|
<a class="underline decoration-slate-500 sm:decoration-2"
|
||||||
href="{% url 'view_game' game_id %}">
|
href="{% url 'games:view_game' game_id %}">
|
||||||
{% if slot %}
|
{% if slot %}
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
|
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
{% if badge %}
|
{% if badge %}
|
||||||
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ms-2">
|
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2">
|
||||||
{{ badge }}
|
{{ badge }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg class="dark:text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 284 B |
@@ -2,7 +2,7 @@
|
|||||||
x="0px"
|
x="0px"
|
||||||
y="0px"
|
y="0px"
|
||||||
viewBox="0 0 48 48"
|
viewBox="0 0 48 48"
|
||||||
class="text-black dark:text-white w-4 h-4">
|
class="w-4 h-4">
|
||||||
<path fill="currentColor" d="M 11.396484 4.1113281 C 9.1042001 4.2020187 7 6.0721788 7 8.5917969 L 7 39.408203 C 7 42.767694 10.742758 44.971891 13.681641 43.34375 L 41.490234 27.935547 C 44.513674 26.260259 44.513674 21.739741 41.490234 20.064453 L 13.681641 4.65625 C 12.94692 4.2492148 12.160579 4.0810979 11.396484 4.1113281 z M 11.431641 7.0664062 C 11.690234 7.0652962 11.961284 7.1323321 12.226562 7.2792969 L 40.037109 22.6875 C 41.13567 23.296212 41.13567 24.703788 40.037109 25.3125 L 12.226562 40.720703 C 11.165446 41.308562 10 40.620712 10 39.408203 L 10 8.5917969 C 10 7.9855423 10.290709 7.5116121 10.714844 7.2617188 C 10.926911 7.136772 11.173048 7.0675163 11.431641 7.0664062 z">
|
<path fill="currentColor" d="M 11.396484 4.1113281 C 9.1042001 4.2020187 7 6.0721788 7 8.5917969 L 7 39.408203 C 7 42.767694 10.742758 44.971891 13.681641 43.34375 L 41.490234 27.935547 C 44.513674 26.260259 44.513674 21.739741 41.490234 20.064453 L 13.681641 4.65625 C 12.94692 4.2492148 12.160579 4.0810979 11.396484 4.1113281 z M 11.431641 7.0664062 C 11.690234 7.0652962 11.961284 7.1323321 12.226562 7.2792969 L 40.037109 22.6875 C 41.13567 23.296212 41.13567 24.703788 40.037109 25.3125 L 12.226562 40.720703 C 11.165446 41.308562 10 40.620712 10 39.408203 L 10 8.5917969 C 10 7.9855423 10.290709 7.5116121 10.714844 7.2617188 C 10.926911 7.136772 11.173048 7.0675163 11.431641 7.0664062 z">
|
||||||
</path>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 861 B After Width: | Height: | Size: 834 B |
@@ -3,12 +3,16 @@
|
|||||||
{% if form_content %}
|
{% if form_content %}
|
||||||
{{ form_content }}
|
{{ form_content }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="max-width-container">
|
<div id="add-form" class="max-width-container">
|
||||||
<div class="form-container max-w-xl mx-auto">
|
<div id="add-form" class="form-container max-w-xl mx-auto">
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_div }}
|
{{ form.as_div }}
|
||||||
<div><input type="submit" value="Submit" /></div>
|
<div>
|
||||||
|
<c-button type="submit" class="mt-3">
|
||||||
|
Submit
|
||||||
|
</c-button>
|
||||||
|
</div>
|
||||||
<div class="submit-button-container">
|
<div class="submit-button-container">
|
||||||
{{ additional_row }}
|
{{ additional_row }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Timetracker - {{ title }}</title>
|
<title>Timetracker - {{ title }}</title>
|
||||||
<script src="{% static 'js/htmx.min.js' %}"></script>
|
<script src="{% static 'js/htmx.min.js' %}"></script>
|
||||||
|
<script>
|
||||||
|
htmx.config.scrollBehavior = 'smooth';
|
||||||
|
htmx.config.selfRequestsOnly = false;
|
||||||
|
</script>
|
||||||
|
<script src="{% static 'js/htmx-redirect-toast.js' %}"></script>
|
||||||
{% django_htmx_script %}
|
{% django_htmx_script %}
|
||||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
|
||||||
@@ -25,7 +30,14 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-indicator="#indicator">
|
<body hx-indicator="#indicator" class="bg-neutral-primary">
|
||||||
|
<script id="django-messages" type="application/json">
|
||||||
|
[
|
||||||
|
{% for message in messages %}
|
||||||
|
{"message": "{{ message|escapejs }}", "type": "{{ message.tags|default:'info' }}"}{% if not forloop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
<img id="indicator"
|
<img id="indicator"
|
||||||
src="{% static 'icons/loading.png' %}"
|
src="{% static 'icons/loading.png' %}"
|
||||||
class="absolute right-3 top-3 animate-spin htmx-indicator"
|
class="absolute right-3 top-3 animate-spin htmx-indicator"
|
||||||
@@ -34,52 +46,167 @@
|
|||||||
alt="loading indicator" />
|
alt="loading indicator" />
|
||||||
<div class="flex flex-col min-h-screen">
|
<div class="flex flex-col min-h-screen">
|
||||||
{% include "navbar.html" %}
|
{% include "navbar.html" %}
|
||||||
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8">{{ slot }}</div>
|
<div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{{ slot }}</div>
|
||||||
{% load version %}
|
{% load version %}
|
||||||
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
|
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
|
||||||
</div>
|
</div>
|
||||||
{{ scripts }}
|
{{ scripts }}
|
||||||
<script>
|
<script type="module">
|
||||||
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
if (window.mountCrownIcon) {
|
||||||
|
window.mountCrownIcon('#crown-icon-mount-point', {
|
||||||
// Change the icons inside the button based on previous settings
|
mastered: {{ game.mastered|yesno:"true,false" }}
|
||||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
});
|
||||||
themeToggleLightIcon.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
themeToggleDarkIcon.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
var themeToggleBtn = document.getElementById('theme-toggle');
|
|
||||||
|
|
||||||
themeToggleBtn.addEventListener('click', function () {
|
|
||||||
|
|
||||||
// toggle icons inside button
|
|
||||||
themeToggleDarkIcon.classList.toggle('hidden');
|
|
||||||
themeToggleLightIcon.classList.toggle('hidden');
|
|
||||||
|
|
||||||
// if set via local storage previously
|
|
||||||
if (localStorage.getItem('color-theme')) {
|
|
||||||
if (localStorage.getItem('color-theme') === 'light') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
localStorage.setItem('color-theme', 'dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
localStorage.setItem('color-theme', 'light');
|
|
||||||
}
|
|
||||||
|
|
||||||
// if NOT set via local storage previously
|
|
||||||
} else {
|
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
localStorage.setItem('color-theme', 'light');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
localStorage.setItem('color-theme', 'dark');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme toggle logic
|
||||||
|
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||||
|
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||||
|
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||||
|
|
||||||
|
// Ensure all elements are found before proceeding
|
||||||
|
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
|
||||||
|
// Initial state of icons based on current theme
|
||||||
|
// The FOUC script in <head> already set document.documentElement.classList.add/remove('dark')
|
||||||
|
// So we just need to set the icon visibility based on that.
|
||||||
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
|
themeToggleLightIcon.classList.remove('hidden');
|
||||||
|
themeToggleDarkIcon.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
themeToggleDarkIcon.classList.remove('hidden');
|
||||||
|
themeToggleLightIcon.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
themeToggleBtn.addEventListener('click', function () {
|
||||||
|
// toggle icons inside button
|
||||||
|
themeToggleDarkIcon.classList.toggle('hidden');
|
||||||
|
themeToggleLightIcon.classList.toggle('hidden');
|
||||||
|
|
||||||
|
// if set via local storage previously
|
||||||
|
if (localStorage.getItem('color-theme')) {
|
||||||
|
if (localStorage.getItem('color-theme') === 'light') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('color-theme', 'dark');
|
||||||
|
} else { // current theme is dark, switch to light
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('color-theme', 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if NOT set via local storage previously
|
||||||
|
} else { // no theme in local storage, use system preference
|
||||||
|
if (document.documentElement.classList.contains('dark')) { // currently dark, switch to light
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('color-theme', 'light');
|
||||||
|
} else { // currently light, switch to dark
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('color-theme', 'dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
// hx-swap-oob makes sure the modal gets removed upon any HTMX response
|
||||||
|
<div id="global-modal-container" hx-swap-oob="true"></div>
|
||||||
|
|
||||||
|
<div x-data="toastStore()"
|
||||||
|
role="region"
|
||||||
|
aria-label="Notifications"
|
||||||
|
aria-atomic="true"
|
||||||
|
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
|
||||||
|
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
|
||||||
|
<div x-show="toast.visible"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 translate-x-8"
|
||||||
|
x-transition:enter-end="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave-end="opacity-0 translate-x-8"
|
||||||
|
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
|
||||||
|
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
|
||||||
|
tabindex="0"
|
||||||
|
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
|
||||||
|
:class="{
|
||||||
|
'success': toast.type === 'success',
|
||||||
|
'error': toast.type === 'error',
|
||||||
|
'info': toast.type === 'info',
|
||||||
|
'warning': toast.type === 'warning',
|
||||||
|
'debug': toast.type === 'debug'
|
||||||
|
}"
|
||||||
|
@click="dismissToast(toast.id)"
|
||||||
|
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
|
||||||
|
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
|
||||||
|
@keydown.escape="dismissToast(toast.id)">
|
||||||
|
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
|
||||||
|
:class="{
|
||||||
|
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
|
||||||
|
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
|
||||||
|
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
|
||||||
|
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
|
||||||
|
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
|
||||||
|
}">
|
||||||
|
<span class="flex-shrink-0 mt-0.5"
|
||||||
|
:class="{
|
||||||
|
'text-green-500': toast.type === 'success',
|
||||||
|
'text-red-500': toast.type === 'error',
|
||||||
|
'text-blue-500': toast.type === 'info',
|
||||||
|
'text-amber-500': toast.type === 'warning',
|
||||||
|
'text-gray-500': toast.type === 'debug'
|
||||||
|
}">
|
||||||
|
<template x-if="toast.type === 'success'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="toast.type === 'error'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="toast.type === 'info'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="toast.type === 'warning'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
<template x-if="toast.type === 'debug'">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<p class="flex-1 text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-green-800 dark:text-green-200': toast.type === 'success',
|
||||||
|
'text-red-800 dark:text-red-200': toast.type === 'error',
|
||||||
|
'text-blue-800 dark:text-blue-200': toast.type === 'info',
|
||||||
|
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
|
||||||
|
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
|
||||||
|
}"
|
||||||
|
x-text="toast.message"></p>
|
||||||
|
<button @click.stop="dismissToast(toast.id)"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
:class="{
|
||||||
|
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
|
||||||
|
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
|
||||||
|
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
|
||||||
|
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
|
||||||
|
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
|
||||||
|
}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="{% static 'js/toast.js' %}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<c-vars without_buttons="false" submit_text="Submit" close_text="Cancel" />
|
||||||
|
<div id="modal-container">
|
||||||
|
<div class="tt-modal fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto h-full w-full flex items-center justify-center">
|
||||||
|
<div class="relative mx-auto p-5 border-accent border w-full max-w-md shadow-lg/50 rounded-md bg-white dark:bg-gray-900">
|
||||||
|
<div class="{{ container_class }}">
|
||||||
|
{{ slot }}
|
||||||
|
{% if not without_buttons %}
|
||||||
|
<div class="items-center mt-5">
|
||||||
|
<c-button color="blue" size="lg" type="submit" class="w-full">{{ submit_text }}</c-button>
|
||||||
|
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('.tt-modal').remove()">{{ close_text }}</c-button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div data-popover
|
<div data-popover
|
||||||
id="{{ id }}"
|
id="{{ id }}"
|
||||||
role="tooltip"
|
role="tooltip"
|
||||||
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
|
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
|
||||||
<div class="px-3 py-2">{{ popover_content }}</div>
|
<div class="px-3 py-2">{{ popover_content }}</div>
|
||||||
<div data-popper-arrow></div>
|
<div data-popper-arrow></div>
|
||||||
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
|
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
<c-vars :name="id" />
|
<c-vars :name="id" />
|
||||||
<div class="pb-4 bg-white dark:bg-gray-900">
|
<!-- <div class="pb-4 bg-white dark:bg-gray-900">
|
||||||
<label for="table-search" class="sr-only">Search</label>
|
<label for="table-search" class="sr-only">Search</label>
|
||||||
<div class="relative mt-1">
|
<div class="relative mt-1">
|
||||||
<div class="absolute inset-y-0 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none">
|
<div class="absolute inset-y-3 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||||
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="{{ id }}" name="{{ name }}" value="{{ search_string }}" class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}">
|
<input type="text" id="{{ id }}" name="{{ name }}" value="{{ search_string }}" class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
|
|
||||||
|
<form class="max-w-md mx-auto">
|
||||||
|
<label for="search" class="block mb-2.5 text-sm font-medium text-heading sr-only ">Search</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">
|
||||||
|
<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path stroke="currentColor" stroke-linecap="round" stroke-width="2" d="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/></svg>
|
||||||
|
</div>
|
||||||
|
<input type="search" id="{{ id }}" name="{{ name }}" value="{{ search_string }}" class="block w-full p-3 ps-9 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body" placeholder="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}" required />
|
||||||
|
<button type="button" class="absolute end-1.5 bottom-1.5 text-white bg-brand hover:bg-brand-strong box-border border border-transparent focus:ring-4 focus:ring-brand-medium shadow-xs font-medium leading-5 rounded text-xs px-3 py-1.5 focus:outline-none cursor-pointer">Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
{{ header_action }}
|
{{ header_action }}
|
||||||
</c-table-header>
|
</c-table-header>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 [&_th:not(:first-child):not(:last-child)]:max-sm:hidden">
|
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">
|
||||||
<tr>
|
<tr>
|
||||||
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
|
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden">
|
<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">
|
||||||
{% for row in rows %}<c-table-row :data=row />{% endfor %}
|
{% for row in rows %}<c-table-row :data=row />{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2 [&_td:last-child]:text-right">
|
<tr class="odd:bg-white dark:odd:bg-gray-900 even:bg-gray-50 dark:even:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 [&_a]:decoration-2 [&_td:last-child]:text-right"
|
||||||
|
{% if data.row_id %}id="{{ data.row_id }}"{% endif %}
|
||||||
|
{% if data.hx_trigger %}hx-trigger="{{ data.hx_trigger }}"{% endif %}
|
||||||
|
{% if data.hx_get %}hx-get="{{ data.hx_get }}"{% endif %}
|
||||||
|
{% if data.hx_select %}hx-select="{{ data.hx_select }}"{% endif %}
|
||||||
|
{% if data.hx_swap %}hx-swap="{{ data.hx_swap }}"{% endif %}
|
||||||
|
>
|
||||||
{% if slot %}
|
{% if slot %}
|
||||||
{{ slot }}
|
{{ slot }}
|
||||||
|
{% elif data.row_id %}
|
||||||
|
{% for td in data.cell_data %}
|
||||||
|
{% if forloop.first %}
|
||||||
|
<th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
|
||||||
|
{% else %}
|
||||||
|
<c-table-td>
|
||||||
|
{{ td }}
|
||||||
|
</c-table-td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for td in data %}
|
{% for td in data %}
|
||||||
{% if forloop.first %}
|
{% if forloop.first %}
|
||||||
<th scope="row"
|
<th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
|
||||||
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<c-table-td>
|
<c-table-td>
|
||||||
{{ td }}
|
{{ td }}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<c-layouts.base>
|
<c-layouts.base>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
|
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
|
||||||
<form method="post" class="dark:text-white">
|
<form method="post" class="dark:text-white">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div>
|
<div>
|
||||||
<p>Are you sure you want to delete this status change?</p>
|
<p>Are you sure you want to delete this status change?</p>
|
||||||
<c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button>
|
<c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button>
|
||||||
<a href="{% url 'view_game' object.game.id %}" class="">
|
<a href="{% url 'games:view_game' object.game.id %}" class="">
|
||||||
<c-button color="gray" class="w-full">Cancel</c-button>
|
<c-button color="gray" class="w-full">Cancel</c-button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<c-layouts.base>
|
<c-layouts.base>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
|
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
|
||||||
<c-simple-table :columns=["Test"] :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
<c-simple-table :columns=["Test"] :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
||||||
</div>
|
</div>
|
||||||
</c-layouts.base>
|
</c-layouts.base>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{{ title }}
|
{{ title }}
|
||||||
{% endblock title %}
|
{% endblock title %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="text-slate-300 mx-auto max-w-screen-lg text-center">
|
<div class="text-slate-300 mx-auto max-w-(--breakpoint-lg) text-center">
|
||||||
{% if session_count > 0 %}
|
{% if session_count > 0 %}
|
||||||
You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
|
You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
|
||||||
{% elif not game_available or not platform_available %}
|
{% elif not game_available or not platform_available %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<c-layouts.base>
|
<c-layouts.base>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
|
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
|
||||||
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
||||||
</div>
|
</div>
|
||||||
</c-layouts.base>
|
</c-layouts.base>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<c-layouts.base>
|
<c-layouts.base>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center">
|
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
|
||||||
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
|
||||||
</div>
|
</div>
|
||||||
</c-layouts.base>
|
</c-layouts.base>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex-col">
|
<div class="flex-col">
|
||||||
{% if dataset_count >= 1 %}
|
{% if dataset_count >= 1 %}
|
||||||
{% url 'list_sessions_start_session_from_session' last.id as start_session_url %}
|
{% url 'games:list_sessions_start_session_from_session' last.id as start_session_url %}
|
||||||
<div class="mx-auto text-center my-4">
|
<div class="mx-auto text-center my-4">
|
||||||
<a id="last-session-start"
|
<a id="last-session-start"
|
||||||
href="{{ start_session_url }}"
|
href="{{ start_session_url }}"
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
|
||||||
<span class="inline-block relative">
|
<span class="inline-block relative">
|
||||||
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
|
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-xs group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
|
||||||
href="{% url 'view_game' session.game.id %}">
|
href="{% url 'games:view_game' session.game.id %}">
|
||||||
{{ session.game.name }}
|
{{ session.game.name }}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
|
||||||
{% if not session.timestamp_end %}
|
{% if not session.timestamp_end %}
|
||||||
{% url 'list_sessions_end_session' session.id as end_session_url %}
|
{% url 'games:list_sessions_end_session' session.id as end_session_url %}
|
||||||
<a href="{{ end_session_url }}"
|
<a href="{{ end_session_url }}"
|
||||||
hx-get="{{ end_session_url }}"
|
hx-get="{{ end_session_url }}"
|
||||||
hx-target="closest tr"
|
hx-target="closest tr"
|
||||||
|
|||||||
+36
-26
@@ -1,7 +1,7 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<nav class="bg-white border-gray-200 dark:bg-gray-900 dark:border-gray-700">
|
<nav class="bg-neutral-primary-soft border-b border-default">
|
||||||
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
|
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
|
||||||
<a href="{% url 'index' %}"
|
<a href="{% url 'games:index' %}"
|
||||||
class="flex items-center space-x-3 rtl:space-x-reverse">
|
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||||
<img src="{% static 'icons/schedule.png' %}"
|
<img src="{% static 'icons/schedule.png' %}"
|
||||||
height="48"
|
height="48"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<button data-collapse-toggle="navbar-dropdown"
|
<button data-collapse-toggle="navbar-dropdown"
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||||
aria-controls="navbar-dropdown"
|
aria-controls="navbar-dropdown"
|
||||||
aria-expanded="false">
|
aria-expanded="false">
|
||||||
<span class="sr-only">Open main menu</span>
|
<span class="sr-only">Open main menu</span>
|
||||||
@@ -26,19 +26,29 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
|
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
|
||||||
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
||||||
<li class="text-white flex flex-col items-center text-xs">
|
<li class="flex items-center">
|
||||||
<span class="flex uppercase gap-1">Today<span class="text-gray-400">·</span>Last 7 days</span>
|
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
|
||||||
<span class="flex items-center gap-1">{{ today_played }}<span class="text-gray-400">·</span>{{ last_7_played }}</span>
|
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="dark:text-white flex flex-col items-center text-xs">
|
||||||
|
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
|
||||||
|
<span class="flex items-center gap-1">{{ today_played }}<span class="dark:text-gray-400">·</span>{{ last_7_played }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#"
|
<a href="#"
|
||||||
class="block py-2 px-3 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent"
|
class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent"
|
||||||
aria-current="page">Home</a>
|
aria-current="page">Home</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button id="dropdownNavbarNewLink"
|
<button id="dropdownNavbarNewLink"
|
||||||
data-dropdown-toggle="dropdownNavbarNew"
|
data-dropdown-toggle="dropdownNavbarNew"
|
||||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent">
|
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||||
New
|
New
|
||||||
<svg class="w-2.5 h-2.5 ms-2.5"
|
<svg class="w-2.5 h-2.5 ms-2.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -50,27 +60,27 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- Dropdown menu -->
|
<!-- Dropdown menu -->
|
||||||
<div id="dropdownNavbarNew"
|
<div id="dropdownNavbarNew"
|
||||||
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600">
|
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
|
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
|
||||||
aria-labelledby="dropdownLargeButton">
|
aria-labelledby="dropdownLargeButton">
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'add_device' %}"
|
<a href="{% url 'games:add_device' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'add_game' %}"
|
<a href="{% url 'games:add_game' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'add_platform' %}"
|
<a href="{% url 'games:add_platform' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'add_purchase' %}"
|
<a href="{% url 'games:add_purchase' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'add_session' %}"
|
<a href="{% url 'games:add_session' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -79,7 +89,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<button id="dropdownNavbarManageLink"
|
<button id="dropdownNavbarManageLink"
|
||||||
data-dropdown-toggle="dropdownNavbarManage"
|
data-dropdown-toggle="dropdownNavbarManage"
|
||||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent">
|
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||||
Manage
|
Manage
|
||||||
<svg class="w-2.5 h-2.5 ms-2.5"
|
<svg class="w-2.5 h-2.5 ms-2.5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -91,43 +101,43 @@
|
|||||||
</button>
|
</button>
|
||||||
<!-- Dropdown menu -->
|
<!-- Dropdown menu -->
|
||||||
<div id="dropdownNavbarManage"
|
<div id="dropdownNavbarManage"
|
||||||
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600">
|
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
|
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
|
||||||
aria-labelledby="dropdownLargeButton">
|
aria-labelledby="dropdownLargeButton">
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'list_devices' %}"
|
<a href="{% url 'games:list_devices' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'list_games' %}"
|
<a href="{% url 'games:list_games' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'list_platforms' %}"
|
<a href="{% url 'games:list_platforms' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'list_playevents' %}"
|
<a href="{% url 'games:list_playevents' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'list_purchases' %}"
|
<a href="{% url 'games:list_purchases' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'list_sessions' %}"
|
<a href="{% url 'games:list_sessions' %}"
|
||||||
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a>
|
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'stats_by_year' global_current_year %}"
|
<a href="{% url 'games:stats_by_year' global_current_year %}"
|
||||||
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url 'logout' %}"
|
<a href="{% url 'logout' %}"
|
||||||
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log
|
class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log
|
||||||
out</a>
|
out</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<div id="delete-game-confirmation-modal" class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto h-full w-full flex items-center justify-center">
|
||||||
|
<div class="relative mx-auto p-5 border-accent border w-full max-w-md shadow-lg/50 rounded-md bg-white dark:bg-gray-900">
|
||||||
|
<div class="">
|
||||||
|
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Delete Game</h1>
|
||||||
|
<p class="dark:text-white text-center mt-5">
|
||||||
|
Are you sure you want to delete <strong>{{ game.name }}</strong>?
|
||||||
|
</p>
|
||||||
|
<form class=""
|
||||||
|
hx-post="{% url 'games:delete_game' game.id %}"
|
||||||
|
hx-replace-url="true"
|
||||||
|
hx-target="#main-container"
|
||||||
|
hx-select="#main-container"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hw-swap-oob="#global-modal-container"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
<p class="dark:text-white text-center mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
This will permanently delete this game and all associated data:
|
||||||
|
</p>
|
||||||
|
<ul class="dark:text-white text-center mt-1 text-sm text-gray-600 dark:text-gray-400 list-disc list-inside">
|
||||||
|
{% if session_count %}<li>{{ session_count }} session(s)</li>{% endif %}
|
||||||
|
{% if purchase_count %}<li>{{ purchase_count }} purchase(s)</li>{% endif %}
|
||||||
|
{% if playevent_count %}<li>{{ playevent_count }} play event(s)</li>{% endif %}
|
||||||
|
{% if not session_count and not purchase_count and not playevent_count %}<li>No associated data</li>{% endif %}
|
||||||
|
</ul>
|
||||||
|
<p class="dark:text-white text-center mt-3 text-sm font-medium text-red-600 dark:text-red-400">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<div class="items-center mt-5">
|
||||||
|
<c-button color="red" size="lg" type="submit" class="w-full">Delete</c-button>
|
||||||
|
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('#delete-game-confirmation-modal').remove()">Cancel</c-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -8,40 +8,42 @@
|
|||||||
this.status = newStatus;
|
this.status = newStatus;
|
||||||
this.status_display = newStatusDisplay;
|
this.status_display = newStatusDisplay;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
fetch(`/api/games/{{ game.id }}/status`, {
|
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||||
method: 'PATCH',
|
fetchWithHtmxTriggers(`/api/games/{{ game.id }}/status`, {
|
||||||
headers: {
|
method: 'PATCH',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
'X-CSRFToken': '{{ csrf_token }}'
|
'Content-Type': 'application/json',
|
||||||
},
|
'X-CSRFToken': '{{ csrf_token }}'
|
||||||
body: JSON.stringify({ status: newStatus })
|
},
|
||||||
}).then(() => {
|
body: JSON.stringify({ status: newStatus })
|
||||||
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
})
|
||||||
})
|
.then(() => {
|
||||||
.finally(() => this.saving = false);
|
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.error('Failed to update status');
|
||||||
|
})
|
||||||
|
.finally(() => this.saving = false);
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="inline-flex rounded-md shadow-xs" role="group" @click.outside="open = false">
|
<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">
|
||||||
<button type="button" @click="open = !open" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle">
|
<button type="button" @click="open = !open" class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
||||||
<span class="flex flex-row gap-4 justify-between items-center">
|
<span class="flex flex-row gap-4 justify-between items-center">
|
||||||
{% for status_value, status_label in game_statuses %}
|
{% for status_value, status_label in game_statuses %}
|
||||||
<template x-if="status == '{{ status_value }}'">
|
<template x-if="status == '{{ status_value }}'">
|
||||||
<c-gamestatus display="flex" status="{{ status_value }}" class="text-slate-300">{{ status_label }}</c-gamestatus>
|
<c-gamestatus display="flex" status="{{ status_value }}">{{ status_label }}</c-gamestatus>
|
||||||
</template>
|
</template>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<svg class="text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<c-icon.arrowdown />
|
||||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
</span>
|
||||||
<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">
|
<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">
|
||||||
<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">
|
<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">
|
||||||
{% for status_value, status_label in game_statuses %}
|
{% for status_value, status_label in game_statuses %}
|
||||||
<li><a href="#" @click.prevent.stop="setStatus('{{ status_value }}', '{{ status_label }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded !no-underline !border-0" :class="{ 'font-bold': status === '{{ status_value }}' }"><c-gamestatus display="flex" status="{{ status_value }}" class="text-slate-300">{{ status_label }}</c-gamestatus></a></li>
|
<li><a href="#" @click.prevent.stop="setStatus('{{ status_value }}', '{{ status_label }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': status === '{{ status_value }}' }"><c-gamestatus display="flex" status="{{ status_value }}" class="text-slate-300">{{ status_label }}</c-gamestatus></a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="saving" style="display: none;">Saving...</div>
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<ul class="list-disc list-inside">
|
<ul class="list-disc list-inside">
|
||||||
{% for change in statuschanges %}
|
{% for change in statuschanges %}
|
||||||
<li class="text-slate-500">
|
<li class="text-slate-500">
|
||||||
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status" class="text-white">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status" class="text-white">{{ change.get_new_status_display }}</c-gamestatus> (<a href="{% url 'edit_statuschange' change.id %}">Edit</a>, <a href="{% url 'delete_statuschange' change.id %}">Delete</a>)</li>
|
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status">{{ change.get_new_status_display }}</c-gamestatus> (<a href="{% url 'games:edit_statuschange' change.id %}">Edit</a>, <a href="{% url 'games:delete_statuschange' change.id %}">Delete</a>)</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<div id="refund-confirmation-modal" class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto h-full w-full flex items-center justify-center">
|
||||||
|
<div class="relative mx-auto p-5 border-accent border w-full max-w-md shadow-lg/50 rounded-md bg-white dark:bg-gray-900">
|
||||||
|
<div class="">
|
||||||
|
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Confirm Refund</h1>
|
||||||
|
<p class="dark:text-white text-center mt-5">
|
||||||
|
Are you sure you want to mark this purchase as refunded?
|
||||||
|
</p>
|
||||||
|
<form class="" hx-post="{% url 'games:refund_purchase' purchase_id %}" hx-target="#purchase-row-{{ purchase_id }}" hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p class="dark:text-white text-center mt-3 text-sm">
|
||||||
|
Games will be marked as abandoned.
|
||||||
|
</p>
|
||||||
|
<div class="items-center mt-5">
|
||||||
|
<c-button color="blue" size="lg" type="submit" class="w-full">Refund</c-button>
|
||||||
|
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('#refund-confirmation-modal').remove()">Cancel</c-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<div class="flex gap-2 items-center"
|
||||||
|
x-data="{
|
||||||
|
originalDeviceId: {{ session.device.id|default:'null' }},
|
||||||
|
originalDeviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
|
||||||
|
deviceId: {{ session.device.id|default:'null' }},
|
||||||
|
deviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
|
||||||
|
open: false,
|
||||||
|
saving: false,
|
||||||
|
setDevice(newDeviceId, newDeviceName) {
|
||||||
|
this.deviceId = newDeviceId;
|
||||||
|
this.deviceName = newDeviceName;
|
||||||
|
this.saving = true;
|
||||||
|
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||||
|
fetchWithHtmxTriggers(`/api/session/{{ session.id }}/device`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ device_id: newDeviceId })
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.deviceName = this.originalDeviceName;
|
||||||
|
this.deviceId = this.originalDeviceId;
|
||||||
|
console.error('Failed to update device');
|
||||||
|
})
|
||||||
|
.finally(() => this.saving = false);
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">
|
||||||
|
<button type="button" @click="open = !open" class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
||||||
|
<span class="flex flex-row gap-4 justify-between items-center">
|
||||||
|
<span x-text="deviceName"></span>
|
||||||
|
<c-icon.arrowdown />
|
||||||
|
</span>
|
||||||
|
<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">
|
||||||
|
<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">
|
||||||
|
{% for device in session_devices %}
|
||||||
|
<li><a href="#" @click.prevent.stop="setDevice({{ device.id }}, '{{ device.name|escapejs }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': deviceId === {{ device.id }} }">{{ device.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
||||||
<div id="game-info" class="mb-10">
|
<div id="game-info" class="mb-10">
|
||||||
<div class="flex gap-5 mb-3">
|
<div class="flex gap-5 mb-3">
|
||||||
<span class="text-balance max-w-[30rem] text-4xl">
|
<span class="text-balance max-w-120 text-4xl">
|
||||||
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %} <c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
|
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %} <c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,10 +52,10 @@
|
|||||||
{{ playrange }}
|
{{ playrange }}
|
||||||
</c-popover>
|
</c-popover>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col mb-6 text-slate-400 gap-y-4">
|
<div class="flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4">
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<span class="uppercase">Original year</span>
|
<span class="uppercase">Original year</span>
|
||||||
<span class="text-slate-300">{{ game.original_year_released }}</span>
|
<span class="text-black dark:text-slate-300">{{ game.original_year_released }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 items-center"
|
<div class="flex gap-2 items-center"
|
||||||
>
|
>
|
||||||
@@ -67,25 +67,23 @@
|
|||||||
x-data="{ open: false }"
|
x-data="{ open: false }"
|
||||||
>
|
>
|
||||||
<span class="uppercase">Played</span>
|
<span class="uppercase">Played</span>
|
||||||
<div class="inline-flex rounded-md shadow-xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
|
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
|
||||||
<a href="{% url 'add_playevent' %}">
|
<a href="{% url 'games:add_playevent' %}">
|
||||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||||
<span x-text="played"></span> times
|
<span x-text="played"></span> times
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle">
|
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
|
||||||
<svg class="text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<c-icon.arrowdown />
|
||||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<div
|
<div
|
||||||
class="absolute top-[100%] -left-[1px] w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
||||||
x-show="open"
|
x-show="open"
|
||||||
>
|
>
|
||||||
<ul
|
<ul
|
||||||
class=""
|
class=""
|
||||||
>
|
>
|
||||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
||||||
<a href="{% url 'add_playevent_for_game' game.id %}">Add playthrough...</a>
|
<a href="{% url 'games:add_playevent_for_game' game.id %}">Add playthrough...</a>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
x-on:click="createPlayEvent"
|
x-on:click="createPlayEvent"
|
||||||
@@ -96,7 +94,16 @@
|
|||||||
<script>
|
<script>
|
||||||
function createPlayEvent() {
|
function createPlayEvent() {
|
||||||
this.played++;
|
this.played++;
|
||||||
fetch('{% url 'api-1.0.0:create_playevent' %}', { method: 'POST', headers: { 'X-CSRFToken': '{{ csrf_token }}' }, body: '{"game_id": {{ game.id }}}'})
|
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||||
|
fetchWithHtmxTriggers('{% url 'api-1.0.0:create_playevent' %}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/json' },
|
||||||
|
body: '{"game_id": {{ game.id }}}'
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.played--;
|
||||||
|
console.error('Failed to record play');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -110,19 +117,19 @@
|
|||||||
|
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<span class="uppercase">Platform</span>
|
<span class="uppercase">Platform</span>
|
||||||
<span class="text-slate-300">{{ game.platform }}</span>
|
<span class="text-black dark:text-slate-300">{{ game.platform }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
|
<div class="inline-flex rounded-md shadow-xs mb-3" role="group">
|
||||||
<a href="{% url 'edit_game' game.id %}">
|
<a href="{% url 'games:edit_game' game.id %}">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'delete_game' game.id %}">
|
<a href="#" hx-get="{% url 'games:delete_game_confirmation' game.id %}" hx-target="#global-modal-container">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -9,19 +9,19 @@
|
|||||||
{{ purchase.name }}
|
{{ purchase.name }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-balance max-w-[30rem] text-4xl">
|
<span class="text-balance max-w-120 text-4xl">
|
||||||
<span class="font-bold font-serif">
|
<span class="font-bold font-serif">
|
||||||
{{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
|
{{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
|
<div class="inline-flex rounded-md shadow-xs mb-3" role="group">
|
||||||
<a href="{% url 'edit_purchase' purchase.id %}">
|
<a href="{% url 'games:edit_purchase' purchase.id %}">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'delete_purchase' purchase.id %}">
|
<a href="{% url 'games:delete_purchase' purchase.id %}">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import random
|
import hashlib
|
||||||
import string
|
|
||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
|
|
||||||
@@ -8,4 +7,7 @@ register = template.Library()
|
|||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def randomid(seed: str = "") -> str:
|
def randomid(seed: str = "") -> str:
|
||||||
return str(hash(seed + "".join(random.choices(string.ascii_lowercase, k=10))))
|
content_hash = hashlib.sha1(seed.encode()).hexdigest()
|
||||||
|
if seed:
|
||||||
|
return content_hash[:max(0, 10 - len(seed))] + seed
|
||||||
|
return content_hash[:10]
|
||||||
|
|||||||
+100
-1
@@ -1,3 +1,102 @@
|
|||||||
|
from datetime import date
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
from games.models import Game, Platform, Purchase
|
||||||
|
from games.tasks import convert_prices
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseNeedsPriceUpdateTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||||
|
self.game = Game.objects.create(name="Test Game", platform=self.platform)
|
||||||
|
|
||||||
|
def test_new_purchase_has_needs_price_update_true(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
price=50.0,
|
||||||
|
price_currency="USD",
|
||||||
|
date_purchased=date(2025, 1, 1),
|
||||||
|
)
|
||||||
|
purchase.games.add(self.game)
|
||||||
|
self.assertTrue(purchase.needs_price_update)
|
||||||
|
|
||||||
|
def test_convert_prices_sets_flag_to_false(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
price=50.0,
|
||||||
|
price_currency="USD",
|
||||||
|
date_purchased=date(2025, 1, 1),
|
||||||
|
)
|
||||||
|
purchase.games.add(self.game)
|
||||||
|
self.assertTrue(purchase.needs_price_update)
|
||||||
|
|
||||||
|
convert_prices()
|
||||||
|
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertFalse(purchase.needs_price_update)
|
||||||
|
|
||||||
|
def test_price_change_sets_needs_price_update(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
price=50.0,
|
||||||
|
price_currency="USD",
|
||||||
|
date_purchased=date(2025, 1, 1),
|
||||||
|
)
|
||||||
|
purchase.games.add(self.game)
|
||||||
|
purchase.converted_price = 1000
|
||||||
|
purchase.converted_currency = "CZK"
|
||||||
|
purchase.needs_price_update = False
|
||||||
|
purchase.save()
|
||||||
|
|
||||||
|
purchase.price = 60.0
|
||||||
|
purchase.save()
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertTrue(purchase.needs_price_update)
|
||||||
|
|
||||||
|
def test_currency_change_sets_needs_price_update(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
price=50.0,
|
||||||
|
price_currency="USD",
|
||||||
|
date_purchased=date(2025, 1, 1),
|
||||||
|
)
|
||||||
|
purchase.games.add(self.game)
|
||||||
|
purchase.converted_price = 1000
|
||||||
|
purchase.converted_currency = "CZK"
|
||||||
|
purchase.needs_price_update = False
|
||||||
|
purchase.save()
|
||||||
|
|
||||||
|
purchase.price_currency = "EUR"
|
||||||
|
purchase.save()
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertTrue(purchase.needs_price_update)
|
||||||
|
|
||||||
|
def test_name_change_does_not_set_needs_price_update(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
price=50.0,
|
||||||
|
price_currency="USD",
|
||||||
|
date_purchased=date(2025, 1, 1),
|
||||||
|
)
|
||||||
|
purchase.games.add(self.game)
|
||||||
|
purchase.converted_price = 1000
|
||||||
|
purchase.converted_currency = "CZK"
|
||||||
|
purchase.needs_price_update = False
|
||||||
|
purchase.save()
|
||||||
|
|
||||||
|
purchase.name = "New Name"
|
||||||
|
purchase.save()
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertFalse(purchase.needs_price_update)
|
||||||
|
|
||||||
|
def test_convert_prices_skips_already_converted(self):
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
price=50.0,
|
||||||
|
price_currency="USD",
|
||||||
|
date_purchased=date(2025, 1, 1),
|
||||||
|
)
|
||||||
|
purchase.games.add(self.game)
|
||||||
|
purchase.converted_price = 1000
|
||||||
|
purchase.converted_currency = "CZK"
|
||||||
|
purchase.needs_price_update = False
|
||||||
|
purchase.save()
|
||||||
|
|
||||||
|
convert_prices()
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertFalse(purchase.needs_price_update)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
|
app_name = "games"
|
||||||
|
|
||||||
from games.api import api
|
from games.api import api
|
||||||
from games.views import (
|
from games.views import (
|
||||||
device,
|
device,
|
||||||
@@ -21,6 +23,7 @@ urlpatterns = [
|
|||||||
path("game/add", game.add_game, name="add_game"),
|
path("game/add", game.add_game, name="add_game"),
|
||||||
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
|
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
|
||||||
path("game/<int:game_id>/view", game.view_game, name="view_game"),
|
path("game/<int:game_id>/view", game.view_game, name="view_game"),
|
||||||
|
path("game/<int:game_id>/delete/confirm", game.delete_game_confirmation, name="delete_game_confirmation"),
|
||||||
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
|
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
|
||||||
path("game/list", game.list_games, name="list_games"),
|
path("game/list", game.list_games, name="list_games"),
|
||||||
path("platform/add", platform.add_platform, name="add_platform"),
|
path("platform/add", platform.add_platform, name="add_platform"),
|
||||||
@@ -88,6 +91,11 @@ urlpatterns = [
|
|||||||
purchase.list_purchases,
|
purchase.list_purchases,
|
||||||
name="list_purchases",
|
name="list_purchases",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"purchase/<int:purchase_id>/refund/confirm",
|
||||||
|
purchase.refund_purchase_confirmation,
|
||||||
|
name="refund_purchase_confirmation",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"purchase/<int:purchase_id>/refund",
|
"purchase/<int:purchase_id>/refund",
|
||||||
purchase.refund_purchase,
|
purchase.refund_purchase,
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"data": {
|
"data": {
|
||||||
"header_action": A([], Button([], "Add device"), url="add_device"),
|
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Type",
|
"Type",
|
||||||
@@ -53,12 +53,12 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
|||||||
{
|
{
|
||||||
"buttons": [
|
"buttons": [
|
||||||
{
|
{
|
||||||
"href": reverse("edit_device", args=[device.pk]),
|
"href": reverse("games:edit_device", args=[device.pk]),
|
||||||
"slot": Icon("edit"),
|
"slot": Icon("edit"),
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": reverse("delete_device", args=[device.pk]),
|
"href": reverse("games:delete_device", args=[device.pk]),
|
||||||
"slot": Icon("delete"),
|
"slot": Icon("delete"),
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
@@ -79,7 +79,7 @@ def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
|
|||||||
form = DeviceForm(request.POST or None, instance=device)
|
form = DeviceForm(request.POST or None, instance=device)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("list_devices")
|
return redirect("games:list_devices")
|
||||||
|
|
||||||
context: dict[str, Any] = {"form": form, "title": "Edit device"}
|
context: dict[str, Any] = {"form": form, "title": "Edit device"}
|
||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
@@ -89,7 +89,7 @@ def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
|
|||||||
def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
|
def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
|
||||||
device = get_object_or_404(Device, id=device_id)
|
device = get_object_or_404(Device, id=device_id)
|
||||||
device.delete()
|
device.delete()
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -98,7 +98,7 @@ def add_device(request: HttpRequest) -> HttpResponse:
|
|||||||
form = DeviceForm(request.POST or None)
|
form = DeviceForm(request.POST or None)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("index")
|
return redirect("games:index")
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Device"
|
context["title"] = "Add New Device"
|
||||||
|
|||||||
+35
-19
@@ -89,7 +89,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
A([], Button([], "Add game"), url="add_game"),
|
A([], Button([], "Add game"), url_name="games:add_game"),
|
||||||
],
|
],
|
||||||
attributes=[("class", "flex justify-between")],
|
attributes=[("class", "flex justify-between")],
|
||||||
),
|
),
|
||||||
@@ -104,7 +104,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
],
|
],
|
||||||
"rows": [
|
"rows": [
|
||||||
[
|
[
|
||||||
NameWithIcon(game_id=game.pk),
|
NameWithIcon(game=game),
|
||||||
PopoverTruncated(
|
PopoverTruncated(
|
||||||
game.sort_name
|
game.sort_name
|
||||||
if game.sort_name is not None and game.name != game.sort_name
|
if game.sort_name is not None and game.name != game.sort_name
|
||||||
@@ -126,12 +126,12 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
{
|
{
|
||||||
"buttons": [
|
"buttons": [
|
||||||
{
|
{
|
||||||
"href": reverse("edit_game", args=[game.pk]),
|
"href": reverse("games:edit_game", args=[game.pk]),
|
||||||
"slot": Icon("edit"),
|
"slot": Icon("edit"),
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": reverse("delete_game", args=[game.pk]),
|
"href": reverse("games:delete_game", args=[game.pk]),
|
||||||
"slot": Icon("delete"),
|
"slot": Icon("delete"),
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
@@ -154,10 +154,10 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
|||||||
game = form.save()
|
game = form.save()
|
||||||
if "submit_and_redirect" in request.POST:
|
if "submit_and_redirect" in request.POST:
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
reverse("add_purchase_for_game", kwargs={"game_id": game.id})
|
reverse("games:add_purchase_for_game", kwargs={"game_id": game.id})
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return redirect("list_games")
|
return redirect("games:list_games")
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Game"
|
context["title"] = "Add New Game"
|
||||||
@@ -165,11 +165,29 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
|||||||
return render(request, "add_game.html", context)
|
return render(request, "add_game.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_game_confirmation(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||||
|
game = get_object_or_404(Game, id=game_id)
|
||||||
|
session_count = game.sessions.count()
|
||||||
|
purchase_count = game.purchases.count()
|
||||||
|
playevent_count = game.playevents.count()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"partials/delete_game_confirmation.html",
|
||||||
|
{
|
||||||
|
"game": game,
|
||||||
|
"session_count": session_count,
|
||||||
|
"purchase_count": purchase_count,
|
||||||
|
"playevent_count": playevent_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||||
game = get_object_or_404(Game, id=game_id)
|
game = get_object_or_404(Game, id=game_id)
|
||||||
game.delete()
|
game.delete()
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -180,7 +198,7 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
form = GameForm(request.POST or None, instance=purchase)
|
form = GameForm(request.POST or None, instance=purchase)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
context["title"] = "Edit Game"
|
context["title"] = "Edit Game"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
@@ -242,12 +260,12 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
{
|
{
|
||||||
"buttons": [
|
"buttons": [
|
||||||
{
|
{
|
||||||
"href": reverse("edit_purchase", args=[purchase.pk]),
|
"href": reverse("games:edit_purchase", args=[purchase.pk]),
|
||||||
"slot": Icon("edit"),
|
"slot": Icon("edit"),
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": reverse("delete_purchase", args=[purchase.pk]),
|
"href": reverse("games:delete_purchase", args=[purchase.pk]),
|
||||||
"slot": Icon("delete"),
|
"slot": Icon("delete"),
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
@@ -274,7 +292,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
"header_action": Div(
|
"header_action": Div(
|
||||||
children=[
|
children=[
|
||||||
A(
|
A(
|
||||||
url="add_session",
|
url_name="games:add_session",
|
||||||
children=Button(
|
children=Button(
|
||||||
icon=True,
|
icon=True,
|
||||||
size="xs",
|
size="xs",
|
||||||
@@ -282,8 +300,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
A(
|
A(
|
||||||
url=reverse(
|
href=reverse(
|
||||||
"list_sessions_start_session_from_session",
|
"games:list_sessions_start_session_from_session",
|
||||||
args=[last_session.pk],
|
args=[last_session.pk],
|
||||||
),
|
),
|
||||||
children=Popover(
|
children=Popover(
|
||||||
@@ -308,9 +326,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
"columns": ["Game", "Date", "Duration", "Actions"],
|
"columns": ["Game", "Date", "Duration", "Actions"],
|
||||||
"rows": [
|
"rows": [
|
||||||
[
|
[
|
||||||
NameWithIcon(
|
NameWithIcon(session=session),
|
||||||
session_id=session.pk,
|
|
||||||
),
|
|
||||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||||
session.duration_formatted_with_mark,
|
session.duration_formatted_with_mark,
|
||||||
render_to_string(
|
render_to_string(
|
||||||
@@ -319,7 +335,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
"buttons": [
|
"buttons": [
|
||||||
{
|
{
|
||||||
"href": reverse(
|
"href": reverse(
|
||||||
"list_sessions_end_session", args=[session.pk]
|
"games:list_sessions_end_session", args=[session.pk]
|
||||||
),
|
),
|
||||||
"slot": Icon("end"),
|
"slot": Icon("end"),
|
||||||
"title": "Finish session now",
|
"title": "Finish session now",
|
||||||
@@ -333,12 +349,12 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
|||||||
# in the button group component
|
# in the button group component
|
||||||
else {},
|
else {},
|
||||||
{
|
{
|
||||||
"href": reverse("edit_session", args=[session.pk]),
|
"href": reverse("games:edit_session", args=[session.pk]),
|
||||||
"slot": Icon("edit"),
|
"slot": Icon("edit"),
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": reverse("delete_session", args=[session.pk]),
|
"href": reverse("games:delete_session", args=[session.pk]),
|
||||||
"slot": Icon("delete"),
|
"slot": Icon("delete"),
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
|
|||||||
+60
-28
@@ -2,9 +2,9 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
|
from django.db.models import Avg, Count, ExpressionWrapper, F, Max, OuterRef, Prefetch, Q, Subquery, Sum, fields
|
||||||
from django.db.models.functions import TruncDate, TruncMonth
|
from django.db.models.functions import TruncDate, TruncMonth
|
||||||
from django.db.models.manager import BaseManager
|
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -90,26 +90,34 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
this_year_purchases = Purchase.objects.all()
|
this_year_purchases = Purchase.objects.all()
|
||||||
this_year_purchases_with_currency = this_year_purchases.select_related("games")
|
this_year_purchases_with_currency = this_year_purchases.select_related("games")
|
||||||
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
|
this_year_purchases_without_refunded = Purchase.objects.filter(
|
||||||
date_refunded=None
|
date_refunded=None
|
||||||
)
|
)
|
||||||
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
|
this_year_purchases_refunded = Purchase.objects.refunded()
|
||||||
|
|
||||||
this_year_purchases_unfinished_dropped_nondropped = (
|
this_year_purchases_unfinished_dropped_nondropped = (
|
||||||
this_year_purchases_without_refunded.filter(date_finished__isnull=True)
|
this_year_purchases_without_refunded.filter(
|
||||||
|
~Q(games__status="f")
|
||||||
|
& ~Q(games__playevents__ended__isnull=False)
|
||||||
|
)
|
||||||
.filter(infinite=False)
|
.filter(infinite=False)
|
||||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||||
) # do not count battle passes etc.
|
) # do not count battle passes etc.
|
||||||
|
|
||||||
this_year_purchases_unfinished = (
|
this_year_purchases_unfinished = (
|
||||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||||
date_dropped__isnull=True
|
~Q(games__status="r")
|
||||||
|
& ~Q(games__status="a")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
this_year_purchases_dropped = (
|
this_year_purchases_dropped = (
|
||||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
this_year_purchases.filter(
|
||||||
date_dropped__isnull=False
|
~Q(games__status="f")
|
||||||
|
& ~Q(games__playevents__ended__isnull=False)
|
||||||
)
|
)
|
||||||
|
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
||||||
|
.filter(infinite=False)
|
||||||
|
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||||
)
|
)
|
||||||
|
|
||||||
this_year_purchases_without_refunded_count = (
|
this_year_purchases_without_refunded_count = (
|
||||||
@@ -124,13 +132,28 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
* 100
|
* 100
|
||||||
)
|
)
|
||||||
|
|
||||||
purchases_finished_this_year: BaseManager[Purchase] = Purchase.objects.finished()
|
_finished_purchases_qs = Purchase.objects.finished()
|
||||||
purchases_finished_this_year_released_this_year = (
|
_finished_with_date = _finished_purchases_qs.annotate(
|
||||||
purchases_finished_this_year.all().order_by("date_finished")
|
date_finished=Subquery(
|
||||||
|
Purchase.objects.filter(pk=OuterRef("pk"))
|
||||||
|
.annotate(max_ended=Max("games__playevents__ended"))
|
||||||
|
.values("max_ended")[:1]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
purchases_finished_this_year = _finished_with_date
|
||||||
|
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
|
||||||
|
"-date_finished"
|
||||||
)
|
)
|
||||||
purchased_this_year_finished_this_year = (
|
purchased_this_year_finished_this_year = (
|
||||||
this_year_purchases_without_refunded.all()
|
this_year_purchases_without_refunded.filter(pk__in=_finished_purchases_qs.values("pk"))
|
||||||
).order_by("date_finished")
|
.annotate(
|
||||||
|
date_finished=Subquery(
|
||||||
|
Purchase.objects.filter(pk=OuterRef("pk"))
|
||||||
|
.annotate(max_ended=Max("games__playevents__ended"))
|
||||||
|
.values("max_ended")[:1]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by("-date_finished")
|
||||||
|
|
||||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||||
total_spent=Sum(F("converted_price"))
|
total_spent=Sum(F("converted_price"))
|
||||||
@@ -139,7 +162,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
games_with_playtime = Game.objects.filter(
|
games_with_playtime = Game.objects.filter(
|
||||||
sessions__in=this_year_sessions
|
sessions__in=this_year_sessions
|
||||||
).distinct()
|
).distinct().annotate(
|
||||||
|
total_playtime=Sum(F("sessions__duration_total"))
|
||||||
|
).filter(total_playtime__gt=timedelta(0))
|
||||||
month_playtimes = (
|
month_playtimes = (
|
||||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||||
.values("month")
|
.values("month")
|
||||||
@@ -166,11 +191,13 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
backlog_decrease_count = (
|
backlog_decrease_count = (
|
||||||
Purchase.objects.all().intersection(purchases_finished_this_year).count()
|
purchases_finished_this_year.count()
|
||||||
)
|
)
|
||||||
|
|
||||||
first_play_date = "N/A"
|
first_play_date = "N/A"
|
||||||
last_play_date = "N/A"
|
last_play_date = "N/A"
|
||||||
|
first_play_game = None
|
||||||
|
last_play_game = None
|
||||||
if this_year_sessions:
|
if this_year_sessions:
|
||||||
first_session = this_year_sessions.earliest()
|
first_session = this_year_sessions.earliest()
|
||||||
first_play_game = first_session.game
|
first_play_game = first_session.game
|
||||||
@@ -257,9 +284,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
|
|||||||
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||||
selected_year = request.GET.get("year")
|
selected_year = request.GET.get("year")
|
||||||
if selected_year:
|
if selected_year:
|
||||||
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
|
return HttpResponseRedirect(reverse("games:stats_by_year", args=[selected_year]))
|
||||||
if year == 0:
|
if year == 0:
|
||||||
return HttpResponseRedirect(reverse("stats_alltime"))
|
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||||
this_year_sessions = Session.objects.filter(
|
this_year_sessions = Session.objects.filter(
|
||||||
timestamp_start__year=year
|
timestamp_start__year=year
|
||||||
).prefetch_related("game")
|
).prefetch_related("game")
|
||||||
@@ -310,25 +337,30 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
# not infinite
|
# not infinite
|
||||||
# only Game and DLC
|
# only Game and DLC
|
||||||
this_year_purchases_unfinished_dropped_nondropped = (
|
this_year_purchases_unfinished_dropped_nondropped = (
|
||||||
this_year_purchases_without_refunded.exclude(
|
this_year_purchases_without_refunded.filter(
|
||||||
games__in=Game.objects.filter(status="f")
|
~Q(games__status="f")
|
||||||
|
& ~Q(games__playevents__ended__year=year)
|
||||||
)
|
)
|
||||||
.filter(infinite=False)
|
.filter(infinite=False)
|
||||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||||
)
|
)
|
||||||
|
|
||||||
# not finished
|
# unfinished = not finished AND not dropped
|
||||||
this_year_purchases_unfinished = (
|
this_year_purchases_unfinished = (
|
||||||
this_year_purchases_unfinished_dropped_nondropped.exclude(
|
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||||
games__status__in="ura"
|
~Q(games__status="r")
|
||||||
|
& ~Q(games__status="a")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# abandoned
|
# dropped = abandoned OR retired OR refunded (OR logic for transition)
|
||||||
# retired
|
|
||||||
this_year_purchases_dropped = (
|
this_year_purchases_dropped = (
|
||||||
this_year_purchases_unfinished_dropped_nondropped.exclude(
|
this_year_purchases.filter(
|
||||||
games__in=Game.objects.filter(status="ar")
|
~Q(games__status="f")
|
||||||
|
& ~Q(games__playevents__ended__year=year)
|
||||||
)
|
)
|
||||||
|
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
||||||
|
.filter(infinite=False)
|
||||||
|
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||||
)
|
)
|
||||||
|
|
||||||
this_year_purchases_without_refunded_count = (
|
this_year_purchases_without_refunded_count = (
|
||||||
@@ -343,7 +375,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
* 100
|
* 100
|
||||||
)
|
)
|
||||||
|
|
||||||
purchases_finished_this_year = Purchase.objects.filter(
|
purchases_finished_this_year = Purchase.objects.finished().filter(
|
||||||
games__playevents__ended__year=year
|
games__playevents__ended__year=year
|
||||||
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
|
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
|
||||||
purchases_finished_this_year_released_this_year = (
|
purchases_finished_this_year_released_this_year = (
|
||||||
@@ -512,4 +544,4 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def index(request: HttpRequest) -> HttpResponse:
|
def index(request: HttpRequest) -> HttpResponse:
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"data": {
|
"data": {
|
||||||
"header_action": A([], Button([], "Add platform"), url="add_platform"),
|
"header_action": A([], Button([], "Add platform"), url_name="games:add_platform"),
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Icon",
|
"Icon",
|
||||||
@@ -57,14 +57,14 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
"buttons": [
|
"buttons": [
|
||||||
{
|
{
|
||||||
"href": reverse(
|
"href": reverse(
|
||||||
"edit_platform", args=[platform.pk]
|
"games:edit_platform", args=[platform.pk]
|
||||||
),
|
),
|
||||||
"slot": Icon("edit"),
|
"slot": Icon("edit"),
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": reverse(
|
"href": reverse(
|
||||||
"delete_platform", args=[platform.pk]
|
"games:delete_platform", args=[platform.pk]
|
||||||
),
|
),
|
||||||
"slot": Icon("delete"),
|
"slot": Icon("delete"),
|
||||||
"color": "red",
|
"color": "red",
|
||||||
@@ -84,7 +84,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
||||||
platform = get_object_or_404(Platform, id=platform_id)
|
platform = get_object_or_404(Platform, id=platform_id)
|
||||||
platform.delete()
|
platform.delete()
|
||||||
return redirect("list_platforms")
|
return redirect("games:list_platforms")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -95,7 +95,7 @@ def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
|
|||||||
form = PlatformForm(request.POST or None, instance=platform)
|
form = PlatformForm(request.POST or None, instance=platform)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("list_platforms")
|
return redirect("games:list_platforms")
|
||||||
context["title"] = "Edit Platform"
|
context["title"] = "Edit Platform"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
@@ -107,7 +107,7 @@ def add_platform(request: HttpRequest) -> HttpResponse:
|
|||||||
form = PlatformForm(request.POST or None)
|
form = PlatformForm(request.POST or None)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("index")
|
return redirect("games:index")
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Platform"
|
context["title"] = "Add New Platform"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Callable, TypedDict
|
from typing import Any, Callable, TypedDict
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
@@ -11,9 +12,9 @@ from django.template.loader import render_to_string
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from common.components import A, Button, Icon
|
from common.components import A, Button, Icon
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, format_duration, local_strftime
|
||||||
from games.forms import PlayEventForm
|
from games.forms import PlayEventForm
|
||||||
from games.models import Game, PlayEvent
|
from games.models import Game, PlayEvent, Session
|
||||||
|
|
||||||
logger = logging.getLogger("games")
|
logger = logging.getLogger("games")
|
||||||
|
|
||||||
@@ -57,12 +58,12 @@ def create_playevent_tabledata(
|
|||||||
{
|
{
|
||||||
"buttons": [
|
"buttons": [
|
||||||
{
|
{
|
||||||
"href": reverse("edit_playevent", args=[playevent.pk]),
|
"href": reverse("games:edit_playevent", args=[playevent.pk]),
|
||||||
"slot": Icon("edit"),
|
"slot": Icon("edit"),
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"href": reverse("delete_playevent", args=[playevent.pk]),
|
"href": reverse("games:delete_playevent", args=[playevent.pk]),
|
||||||
"slot": Icon("delete"),
|
"slot": Icon("delete"),
|
||||||
"color": "red",
|
"color": "red",
|
||||||
},
|
},
|
||||||
@@ -77,12 +78,45 @@ def create_playevent_tabledata(
|
|||||||
for row in row_list
|
for row in row_list
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"header_action": A([], Button([], "Add play event"), url="add_playevent"),
|
"header_action": A([], Button([], "Add play event"), url_name="games:add_playevent"),
|
||||||
"columns": list(filtered_column_list),
|
"columns": list(filtered_column_list),
|
||||||
"rows": filtered_row_list,
|
"rows": filtered_row_list,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_formatted_playtime_for_game_sessions_in_range(
|
||||||
|
game: Game,
|
||||||
|
start_timestamp: datetime | None = None,
|
||||||
|
end_timestamp: datetime | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Calculates and formats the total playtime for a game's sessions
|
||||||
|
between specified start and end timestamps. If timestamps are not provided,
|
||||||
|
it uses the earliest and latest session start times for the game.
|
||||||
|
Returns "0h 00m" if no sessions exist for the game or if the range is invalid.
|
||||||
|
"""
|
||||||
|
sessions_queryset = game.sessions.all()
|
||||||
|
|
||||||
|
if not sessions_queryset.exists():
|
||||||
|
return "0h 00m"
|
||||||
|
|
||||||
|
actual_start_ts = (
|
||||||
|
start_timestamp
|
||||||
|
if start_timestamp is not None
|
||||||
|
else sessions_queryset.earliest("timestamp_start").timestamp_start
|
||||||
|
)
|
||||||
|
actual_end_ts = (
|
||||||
|
end_timestamp
|
||||||
|
if end_timestamp is not None
|
||||||
|
else sessions_queryset.latest("timestamp_start").timestamp_start
|
||||||
|
)
|
||||||
|
|
||||||
|
sessions_in_range = sessions_queryset.filter(
|
||||||
|
timestamp_start__gte=actual_start_ts, timestamp_start__lte=actual_end_ts
|
||||||
|
)
|
||||||
|
return format_duration(sessions_in_range.total_duration_unformatted(), "%Hh %mm")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_playevents(request: HttpRequest) -> HttpResponse:
|
def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
@@ -115,15 +149,52 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
# coming from add_playevent_for_game url path
|
# coming from add_playevent_for_game url path
|
||||||
game = get_object_or_404(Game, id=game_id)
|
game = get_object_or_404(Game, id=game_id)
|
||||||
initial["game"] = game
|
initial["game"] = game
|
||||||
initial["started"] = game.sessions.earliest().timestamp_start
|
try:
|
||||||
initial["ended"] = game.sessions.latest().timestamp_start
|
# First, try to get the latest session. If no sessions, then no playtime.
|
||||||
|
latest_session = game.sessions.latest("timestamp_start")
|
||||||
|
latest_session_ts = latest_session.timestamp_start
|
||||||
|
|
||||||
|
# Now, determine the start date for the new playevent.
|
||||||
|
# This will be either the day after the last playevent ended, or the earliest session.
|
||||||
|
try:
|
||||||
|
latest_playevent = game.playevents.latest("ended")
|
||||||
|
# Start date for the new PlayEvent form
|
||||||
|
new_playevent_form_start_date = latest_playevent.ended + timedelta(
|
||||||
|
days=1
|
||||||
|
)
|
||||||
|
initial["started"] = new_playevent_form_start_date
|
||||||
|
|
||||||
|
# Start timestamp for playtime calculation
|
||||||
|
playtime_calc_start_ts = datetime.combine(
|
||||||
|
new_playevent_form_start_date, datetime.min.time()
|
||||||
|
)
|
||||||
|
|
||||||
|
except PlayEvent.DoesNotExist:
|
||||||
|
# No previous playevents, so the new playevent starts from the earliest session.
|
||||||
|
earliest_session_ts = game.sessions.earliest(
|
||||||
|
"timestamp_start"
|
||||||
|
).timestamp_start
|
||||||
|
initial["started"] = earliest_session_ts.date()
|
||||||
|
playtime_calc_start_ts = earliest_session_ts
|
||||||
|
|
||||||
|
# The end date for the new PlayEvent form and playtime calculation is the latest session's start date.
|
||||||
|
initial["ended"] = latest_session_ts.date()
|
||||||
|
playtime_calc_end_ts = latest_session_ts
|
||||||
|
|
||||||
|
initial["note"] = _get_formatted_playtime_for_game_sessions_in_range(
|
||||||
|
game, playtime_calc_start_ts, playtime_calc_end_ts
|
||||||
|
)
|
||||||
|
except Session.DoesNotExist:
|
||||||
|
initial["started"] = None
|
||||||
|
initial["ended"] = None
|
||||||
|
initial["note"] = "0h 00m"
|
||||||
form = PlayEventForm(request.POST or None, initial=initial)
|
form = PlayEventForm(request.POST or None, initial=initial)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
if not game_id:
|
if not game_id:
|
||||||
# coming from add_playevent url path
|
# coming from add_playevent url path
|
||||||
game_id = form.instance.game.id
|
game_id = form.instance.game.id
|
||||||
return HttpResponseRedirect(reverse("view_game", args=[game_id]))
|
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
|
||||||
|
|
||||||
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
|
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
|
||||||
|
|
||||||
@@ -134,7 +205,7 @@ def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
|||||||
form = PlayEventForm(request.POST or None, instance=playevent)
|
form = PlayEventForm(request.POST or None, instance=playevent)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return HttpResponseRedirect(reverse("view_game", args=[playevent.game.id]))
|
return HttpResponseRedirect(reverse("games:view_game", args=[playevent.game.id]))
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"form": form,
|
"form": form,
|
||||||
|
|||||||
+120
-74
@@ -1,17 +1,18 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.http import (
|
from django.http import (
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
HttpResponseBadRequest,
|
|
||||||
HttpResponseRedirect,
|
HttpResponseRedirect,
|
||||||
)
|
)
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice
|
from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice
|
||||||
from common.time import dateformat
|
from common.time import dateformat
|
||||||
@@ -20,6 +21,62 @@ from games.models import Game, Purchase
|
|||||||
from games.views.general import use_custom_redirect
|
from games.views.general import use_custom_redirect
|
||||||
|
|
||||||
|
|
||||||
|
def _render_purchase_buttons(purchase_id, is_refunded):
|
||||||
|
"""Return button group HTML for a purchase row."""
|
||||||
|
return render_to_string(
|
||||||
|
"cotton/button_group.html",
|
||||||
|
{
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"href": "#",
|
||||||
|
"hx_get": reverse(
|
||||||
|
"games:refund_purchase_confirmation",
|
||||||
|
args=[purchase_id],
|
||||||
|
),
|
||||||
|
"hx_target": "#global-modal-container",
|
||||||
|
"slot": Icon("refund"),
|
||||||
|
"title": "Mark as refunded",
|
||||||
|
}
|
||||||
|
if not is_refunded
|
||||||
|
else {},
|
||||||
|
{
|
||||||
|
"href": reverse("games:edit_purchase", args=[purchase_id]),
|
||||||
|
"slot": Icon("edit"),
|
||||||
|
"title": "Edit",
|
||||||
|
"color": "gray",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse("games:delete_purchase", args=[purchase_id]),
|
||||||
|
"slot": Icon("delete"),
|
||||||
|
"title": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_purchase_row(purchase):
|
||||||
|
"""Return a row dict for simple-table rendering."""
|
||||||
|
return {
|
||||||
|
"row_id": f"purchase-row-{purchase.id}",
|
||||||
|
"cell_data": [
|
||||||
|
LinkedPurchase(purchase),
|
||||||
|
purchase.get_type_display(),
|
||||||
|
PurchasePrice(purchase),
|
||||||
|
purchase.infinite,
|
||||||
|
purchase.date_purchased.strftime(dateformat),
|
||||||
|
(
|
||||||
|
purchase.date_refunded.strftime(dateformat)
|
||||||
|
if purchase.date_refunded
|
||||||
|
else "-"
|
||||||
|
),
|
||||||
|
purchase.created_at.strftime(dateformat),
|
||||||
|
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def list_purchases(request: HttpRequest) -> HttpResponse:
|
def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||||
context: dict[Any, Any] = {}
|
context: dict[Any, Any] = {}
|
||||||
@@ -43,7 +100,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
"data": {
|
"data": {
|
||||||
"header_action": A([], Button([], "Add purchase"), url="add_purchase"),
|
"header_action": A([], Button([], "Add purchase"), url_name="games:add_purchase"),
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Type",
|
"Type",
|
||||||
@@ -54,54 +111,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
"Created",
|
"Created",
|
||||||
"Actions",
|
"Actions",
|
||||||
],
|
],
|
||||||
"rows": [
|
"rows": [_render_purchase_row(purchase) for purchase in purchases],
|
||||||
[
|
|
||||||
LinkedPurchase(purchase),
|
|
||||||
purchase.get_type_display(),
|
|
||||||
PurchasePrice(purchase),
|
|
||||||
purchase.infinite,
|
|
||||||
purchase.date_purchased.strftime(dateformat),
|
|
||||||
(
|
|
||||||
purchase.date_refunded.strftime(dateformat)
|
|
||||||
if purchase.date_refunded
|
|
||||||
else "-"
|
|
||||||
),
|
|
||||||
purchase.created_at.strftime(dateformat),
|
|
||||||
render_to_string(
|
|
||||||
"cotton/button_group.html",
|
|
||||||
{
|
|
||||||
"buttons": [
|
|
||||||
{
|
|
||||||
"href": reverse(
|
|
||||||
"refund_purchase", args=[purchase.pk]
|
|
||||||
),
|
|
||||||
"slot": Icon("refund"),
|
|
||||||
"title": "Mark as refunded",
|
|
||||||
}
|
|
||||||
if not purchase.date_refunded
|
|
||||||
else {},
|
|
||||||
{
|
|
||||||
"href": reverse(
|
|
||||||
"edit_purchase", args=[purchase.pk]
|
|
||||||
),
|
|
||||||
"slot": Icon("edit"),
|
|
||||||
"title": "Edit",
|
|
||||||
"color": "gray",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"href": reverse(
|
|
||||||
"delete_purchase", args=[purchase.pk]
|
|
||||||
),
|
|
||||||
"slot": Icon("delete"),
|
|
||||||
"title": "Delete",
|
|
||||||
"color": "red",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for purchase in purchases
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return render(request, "list_purchases.html", context)
|
return render(request, "list_purchases.html", context)
|
||||||
@@ -119,12 +129,12 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
if "submit_and_redirect" in request.POST:
|
if "submit_and_redirect" in request.POST:
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
reverse(
|
reverse(
|
||||||
"add_session_for_game",
|
"games:add_session_for_game",
|
||||||
kwargs={"game_id": purchase.first_game.id},
|
kwargs={"game_id": purchase.first_game.id},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return redirect("list_purchases")
|
return redirect("games:list_purchases")
|
||||||
else:
|
else:
|
||||||
if game_id:
|
if game_id:
|
||||||
game = Game.objects.get(id=game_id)
|
game = Game.objects.get(id=game_id)
|
||||||
@@ -140,7 +150,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Purchase"
|
context["title"] = "Add New Purchase"
|
||||||
# context["script_name"] = "add_purchase.js"
|
context["script_name"] = "add_purchase.js"
|
||||||
return render(request, "add_purchase.html", context)
|
return render(request, "add_purchase.html", context)
|
||||||
|
|
||||||
|
|
||||||
@@ -152,11 +162,11 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
form = PurchaseForm(request.POST or None, instance=purchase)
|
form = PurchaseForm(request.POST or None, instance=purchase)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
context["title"] = "Edit Purchase"
|
context["title"] = "Edit Purchase"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["purchase_id"] = str(purchase_id)
|
context["purchase_id"] = str(purchase_id)
|
||||||
# context["script_name"] = "add_purchase.js"
|
context["script_name"] = "add_purchase.js"
|
||||||
return render(request, "add_purchase.html", context)
|
return render(request, "add_purchase.html", context)
|
||||||
|
|
||||||
|
|
||||||
@@ -164,7 +174,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
purchase.delete()
|
purchase.delete()
|
||||||
return redirect("list_purchases")
|
return redirect("games:list_purchases")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -180,35 +190,71 @@ def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
@login_required
|
@login_required
|
||||||
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
purchase.date_dropped = timezone.now()
|
for game in purchase.games.all():
|
||||||
purchase.save()
|
game.status = Game.Status.ABANDONED
|
||||||
return redirect("list_purchases")
|
game.save()
|
||||||
|
return redirect("games:list_purchases")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
def refund_purchase_confirmation(
|
||||||
|
request: HttpRequest, purchase_id: int
|
||||||
|
) -> HttpResponse:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"partials/refund_purchase_confirmation.html",
|
||||||
|
{"purchase_id": purchase_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
purchase.date_refunded = timezone.now()
|
|
||||||
purchase.save()
|
for game in purchase.games.all():
|
||||||
return redirect("list_purchases")
|
game.status = Game.Status.ABANDONED
|
||||||
|
game.save()
|
||||||
|
|
||||||
|
purchase.refund()
|
||||||
|
|
||||||
|
messages.success(request, "Purchase refunded")
|
||||||
|
row_data = _render_purchase_row(purchase)
|
||||||
|
row_html = render_to_string(
|
||||||
|
"cotton/table_row.html",
|
||||||
|
{"data": row_data},
|
||||||
|
)
|
||||||
|
modal_close = (
|
||||||
|
'<template id="refund-confirmation-modal" hx-swap-oob="outerHTML"></template>'
|
||||||
|
)
|
||||||
|
return HttpResponse(row_html + modal_close, status=200)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||||
purchase.date_finished = timezone.now()
|
for game in purchase.games.all():
|
||||||
purchase.save()
|
game.status = Game.Status.FINISHED
|
||||||
return redirect("list_purchases")
|
game.save()
|
||||||
|
return redirect("games:list_purchases")
|
||||||
|
|
||||||
|
|
||||||
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
||||||
|
games: list[str] = []
|
||||||
games = request.GET.getlist("games")
|
games = request.GET.getlist("games")
|
||||||
if not games:
|
context = {}
|
||||||
return HttpResponseBadRequest("Invalid game_id")
|
if games:
|
||||||
if isinstance(games, int) or isinstance(games, str):
|
form = PurchaseForm()
|
||||||
games = [games]
|
qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
|
||||||
form = PurchaseForm()
|
"games__sort_name"
|
||||||
form.fields["related_purchase"].queryset = Purchase.objects.filter(
|
)
|
||||||
games__in=games, type=Purchase.GAME
|
|
||||||
).order_by("games__sort_name")
|
form.fields["related_purchase"].queryset = qs
|
||||||
return render(request, "partials/related_purchase_field.html", {"form": form})
|
first_option = qs.first()
|
||||||
|
if first_option:
|
||||||
|
form.fields["related_purchase"].initial = first_option.id
|
||||||
|
context["form"] = form
|
||||||
|
return render(request, "partials/related_purchase_field.html", context)
|
||||||
|
else:
|
||||||
|
# abort swap
|
||||||
|
return HttpResponse(status=204)
|
||||||
|
|||||||
+75
-58
@@ -25,7 +25,7 @@ from common.time import (
|
|||||||
)
|
)
|
||||||
from common.utils import truncate
|
from common.utils import truncate
|
||||||
from games.forms import SessionForm
|
from games.forms import SessionForm
|
||||||
from games.models import Game, Session
|
from games.models import Device, Game, Session
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -34,6 +34,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
limit = request.GET.get("limit", 10)
|
limit = request.GET.get("limit", 10)
|
||||||
sessions = Session.objects.order_by("-timestamp_start", "created_at")
|
sessions = Session.objects.order_by("-timestamp_start", "created_at")
|
||||||
|
device_list = Device.objects.order_by("name")
|
||||||
search_string = request.GET.get("search_string", search_string)
|
search_string = request.GET.get("search_string", search_string)
|
||||||
if search_string != "":
|
if search_string != "":
|
||||||
sessions = sessions.filter(
|
sessions = sessions.filter(
|
||||||
@@ -80,7 +81,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
Div(
|
Div(
|
||||||
children=[
|
children=[
|
||||||
A(
|
A(
|
||||||
url="add_session",
|
url_name="games:add_session",
|
||||||
children=Button(
|
children=Button(
|
||||||
icon=True,
|
icon=True,
|
||||||
size="xs",
|
size="xs",
|
||||||
@@ -88,8 +89,8 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
A(
|
A(
|
||||||
url=reverse(
|
href=reverse(
|
||||||
"list_sessions_start_session_from_session",
|
"games:list_sessions_start_session_from_session",
|
||||||
args=[last_session.pk],
|
args=[last_session.pk],
|
||||||
),
|
),
|
||||||
children=Popover(
|
children=Popover(
|
||||||
@@ -123,51 +124,66 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
"Actions",
|
"Actions",
|
||||||
],
|
],
|
||||||
"rows": [
|
"rows": [
|
||||||
[
|
{
|
||||||
NameWithIcon(session_id=session.pk),
|
"row_id": f"session-row-{session.pk}",
|
||||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
"hx_trigger": "device-changed from:body",
|
||||||
session.duration_formatted_with_mark,
|
"hx_get": "",
|
||||||
session.device,
|
"hx_select": f"#session-row-{session.pk}",
|
||||||
session.created_at.strftime(dateformat),
|
"hx_swap": "outerHTML",
|
||||||
render_to_string(
|
"cell_data": [
|
||||||
"cotton/button_group.html",
|
NameWithIcon(session=session),
|
||||||
{
|
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||||
"buttons": [
|
session.duration_formatted_with_mark,
|
||||||
{
|
render_to_string(
|
||||||
"href": reverse(
|
"partials/sessiondevice_selector.html",
|
||||||
"list_sessions_end_session", args=[session.pk]
|
{
|
||||||
),
|
"session": session,
|
||||||
"slot": Icon("end"),
|
"session_device": session.device,
|
||||||
"title": "Finish session now",
|
"session_devices": device_list,
|
||||||
"color": "green",
|
},
|
||||||
"hover": "green",
|
request=request,
|
||||||
}
|
),
|
||||||
if session.timestamp_end is None
|
session.created_at.strftime(dateformat),
|
||||||
# this only works without leaving an empty
|
render_to_string(
|
||||||
# a element and wrong rounding of button edges
|
"cotton/button_group.html",
|
||||||
# because we check if button.href is not None
|
{
|
||||||
# in the button group component
|
"buttons": [
|
||||||
else {},
|
{
|
||||||
{
|
"href": reverse(
|
||||||
"href": reverse("edit_session", args=[session.pk]),
|
"games:list_sessions_end_session", args=[session.pk]
|
||||||
"slot": Icon("edit"),
|
),
|
||||||
"title": "Edit",
|
"slot": Icon("end"),
|
||||||
# "color": "gray",
|
"title": "Finish session now",
|
||||||
"hover": "green",
|
"color": "green",
|
||||||
},
|
"hover": "green",
|
||||||
{
|
}
|
||||||
"href": reverse(
|
if session.timestamp_end is None
|
||||||
"delete_session", args=[session.pk]
|
# this only works without leaving an empty
|
||||||
),
|
# a element and wrong rounding of button edges
|
||||||
"slot": Icon("delete"),
|
# because we check if button.href is not None
|
||||||
"title": "Delete",
|
# in the button group component
|
||||||
"color": "red",
|
else {},
|
||||||
"hover": "red",
|
{
|
||||||
},
|
"href": reverse("games:edit_session", args=[session.pk]),
|
||||||
]
|
"slot": Icon("edit"),
|
||||||
},
|
"title": "Edit",
|
||||||
),
|
# "color": "gray",
|
||||||
]
|
"hover": "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"href": reverse(
|
||||||
|
"games:delete_session", args=[session.pk]
|
||||||
|
),
|
||||||
|
"slot": Icon("delete"),
|
||||||
|
"title": "Delete",
|
||||||
|
"color": "red",
|
||||||
|
"hover": "red",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
for session in sessions
|
for session in sessions
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -193,7 +209,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
form = SessionForm(request.POST or None, initial=initial)
|
form = SessionForm(request.POST or None, initial=initial)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
else:
|
else:
|
||||||
if game_id:
|
if game_id:
|
||||||
game = Game.objects.get(id=game_id)
|
game = Game.objects.get(id=game_id)
|
||||||
@@ -208,9 +224,9 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
|
|
||||||
context["title"] = "Add New Session"
|
context["title"] = "Add New Session"
|
||||||
# TODO: re-add custom buttons #91
|
# TODO: re-add custom buttons #91
|
||||||
# context["script_name"] = "add_session.js"
|
context["script_name"] = "add_session.js"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
return render(request, "add.html", context)
|
return render(request, "add_session.html", context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -220,10 +236,11 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
form = SessionForm(request.POST or None, instance=session)
|
form = SessionForm(request.POST or None, instance=session)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
context["title"] = "Edit Session"
|
context["title"] = "Edit Session"
|
||||||
|
context["script_name"] = "add_session.js"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
return render(request, "add.html", context)
|
return render(request, "add_session.html", context)
|
||||||
|
|
||||||
|
|
||||||
def clone_session_by_id(session_id: int) -> Session:
|
def clone_session_by_id(session_id: int) -> Session:
|
||||||
@@ -248,7 +265,7 @@ def new_session_from_existing_session(
|
|||||||
"session_count": int(request.GET.get("session_count", 0)) + 1,
|
"session_count": int(request.GET.get("session_count", 0)) + 1,
|
||||||
}
|
}
|
||||||
return render(request, template, context)
|
return render(request, template, context)
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -264,18 +281,18 @@ def end_session(
|
|||||||
"session_count": request.GET.get("session_count", 0),
|
"session_count": request.GET.get("session_count", 0),
|
||||||
}
|
}
|
||||||
return render(request, template, context)
|
return render(request, template, context)
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
|
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
|
||||||
session = get_object_or_404(Session, id=session_id)
|
session = get_object_or_404(Session, id=session_id)
|
||||||
session.delete()
|
session.delete()
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
|
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
|
||||||
session = get_object_or_404(Session, id=session_id)
|
session = get_object_or_404(Session, id=session_id)
|
||||||
session.delete()
|
session.delete()
|
||||||
return redirect("list_sessions")
|
return redirect("games:list_sessions")
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class EditStatusChangeView(LoginRequiredMixin, UpdateView):
|
|||||||
return get_object_or_404(GameStatusChange, id=self.kwargs["statuschange_id"])
|
return get_object_or_404(GameStatusChange, id=self.kwargs["statuschange_id"])
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse_lazy("list_platforms")
|
return reverse_lazy("games:list_platforms")
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
@@ -31,7 +31,7 @@ class AddStatusChangeView(LoginRequiredMixin, CreateView):
|
|||||||
template_name = "add.html"
|
template_name = "add.html"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse_lazy("view_game", kwargs={"pk": self.object.game.id})
|
return reverse_lazy("games:view_game", kwargs={"pk": self.object.game.id})
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
@@ -54,4 +54,4 @@ class GameStatusChangeDeleteView(LoginRequiredMixin, DeleteView):
|
|||||||
template_name = "gamestatuschange_confirm_delete.html"
|
template_name = "gamestatuschange_confirm_delete.html"
|
||||||
|
|
||||||
def get_success_url(self):
|
def get_success_url(self):
|
||||||
return reverse_lazy("view_game", kwargs={"game_id": self.object.game.id})
|
return reverse_lazy("games:view_game", kwargs={"game_id": self.object.game.id})
|
||||||
|
|||||||
+3
-2
@@ -4,9 +4,10 @@
|
|||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"npm-check-updates": "^16.14.20",
|
"npm-check-updates": "^16.14.20",
|
||||||
"tailwindcss": "^3.4.14"
|
"tailwindcss": "^4.1.18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"flowbite": "^2.4.1"
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
|
"flowbite": "^4.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
-1385
File diff suppressed because it is too large
Load Diff
+53
-36
@@ -1,46 +1,63 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "timetracker"
|
name = "timetracker"
|
||||||
version = "1.6.0"
|
version = "1.6.1"
|
||||||
description = "A simple time tracker."
|
description = "A simple time tracker."
|
||||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
authors = [{ name = "Lukáš Kucharczyk", email = "lukas@kucharczyk.xyz" }]
|
||||||
license = "GPL"
|
requires-python = ">=3.13,<4"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
packages = [{include = "timetracker"}]
|
license = "AGPL-3.0-or-later"
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"django>6.0",
|
||||||
|
"gunicorn>=23.0.0,<24",
|
||||||
|
"uvicorn>=0.30.1,<0.31",
|
||||||
|
"django-htmx>=1.18.0,<2",
|
||||||
|
"django-template-partials>=24.2,<25",
|
||||||
|
"markdown>=3.6,<4",
|
||||||
|
"django-cotton==2.3",
|
||||||
|
"django-q2>=1.7.4,<2",
|
||||||
|
"croniter>=5.0.1,<6",
|
||||||
|
"requests>=2.32.3,<3",
|
||||||
|
"pyyaml>=6.0.2,<7",
|
||||||
|
"django-ninja>=1.6.2",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[project.scripts]
|
||||||
mypy = "^1.10.1"
|
timetracker-import = "common.import_data:import_from_file"
|
||||||
pyyaml = "^6.0.1"
|
|
||||||
pytest = "^8.2.2"
|
|
||||||
django-extensions = "^3.2.3"
|
|
||||||
djhtml = "^3.0.6"
|
|
||||||
djlint = "^1.34.1"
|
|
||||||
isort = "^5.13.2"
|
|
||||||
pre-commit = "^3.7.1"
|
|
||||||
django-debug-toolbar = "^4.4.2"
|
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.10.1,<2",
|
||||||
|
"pyyaml>=6.0.1,<7",
|
||||||
|
"pytest>=8.2.2,<9",
|
||||||
|
"django-extensions>=3.2.3,<4",
|
||||||
|
"djhtml>=3.0.6,<4",
|
||||||
|
"djlint>=1.34.1,<2",
|
||||||
|
"isort>=5.13.2,<6",
|
||||||
|
"pre-commit>=3.7.1,<4",
|
||||||
|
"django-debug-toolbar>=4.4.2,<5",
|
||||||
|
"ruff",
|
||||||
|
"pytest-django>=4.12.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.uv]
|
||||||
python = "^3.11"
|
|
||||||
django = "^5.0.6"
|
|
||||||
gunicorn = "^23.0.0"
|
|
||||||
uvicorn = "^0.30.1"
|
|
||||||
graphene-django = "^3.2.0"
|
|
||||||
django-htmx = "^1.18.0"
|
|
||||||
django-template-partials = "^24.2"
|
|
||||||
markdown = "^3.6"
|
|
||||||
django-cotton = "^1.2.1"
|
|
||||||
|
|
||||||
django-q2 = "^1.7.4"
|
[tool.uv.build-backend]
|
||||||
croniter = "^5.0.1"
|
module-name = ["timetracker"]
|
||||||
requests = "^2.32.3"
|
module-root = ""
|
||||||
pyyaml = "^6.0.2"
|
|
||||||
django-ninja = "^1.3.0"
|
|
||||||
[tool.isort]
|
|
||||||
profile = "black"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["uv_build>=0.9.26,<0.10.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "uv_build"
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.pytest.ini_options]
|
||||||
timetracker-import = "common.import_data:import_from_file"
|
DJANGO_SETTINGS_MODULE = "timetracker.settings"
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ pkgs.mkShell {
|
|||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
nodejs
|
nodejs
|
||||||
python3
|
python3
|
||||||
poetry
|
uv
|
||||||
ruff
|
ruff
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
python -m venv .venv
|
uv venv --clear
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
poetry install
|
uv sync
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
|
||||||
const colors = require('tailwindcss/colors');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
darkMode: 'class',
|
|
||||||
content: ["./games/**/*.{html,js}", './node_modules/flowbite/**/*.js'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
|
|
||||||
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
|
|
||||||
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
|
|
||||||
'condensed': ['IBM Plex Sans Condensed', ...defaultTheme.fontFamily.sans],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
'accent': colors.violet[600],
|
|
||||||
'background': colors.gray[800],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
require('@tailwindcss/typography'),
|
|
||||||
require('@tailwindcss/forms'),
|
|
||||||
require('flowbite/plugin')
|
|
||||||
],
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
import django
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
|
||||||
django.setup()
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from graphene_django.utils.testing import GraphQLTestCase
|
|
||||||
|
|
||||||
from games import schema
|
|
||||||
from games.models import Game
|
|
||||||
|
|
||||||
|
|
||||||
class GameAPITestCase(GraphQLTestCase):
|
|
||||||
GRAPHENE_SCHEMA = schema.schema
|
|
||||||
|
|
||||||
def test_query_all_games(self):
|
|
||||||
response = self.query(
|
|
||||||
"""
|
|
||||||
query {
|
|
||||||
games {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertResponseNoErrors(response)
|
|
||||||
self.assertEqual(
|
|
||||||
len(json.loads(response.content)["data"]["games"]),
|
|
||||||
Game.objects.count(),
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase, Client
|
||||||
|
|
||||||
|
from games.models import Device, Game, Platform, Purchase, Session
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class MiddlewareIntegrationTest(TestCase):
|
||||||
|
"""Integration tests for HTMXMessagesMiddleware.
|
||||||
|
|
||||||
|
These tests hit real endpoints that use messages.success() to verify
|
||||||
|
the full chain: API endpoint → messages → middleware → HX-Trigger header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_user():
|
||||||
|
return User.objects.create_user(
|
||||||
|
username="testuser", password="testpass123"
|
||||||
|
)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.user = self._create_user()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
pl = Platform(name="Test Platform")
|
||||||
|
pl.save()
|
||||||
|
self.game = Game(name="Test Game", platform=pl)
|
||||||
|
self.game.save()
|
||||||
|
|
||||||
|
def test_non_htmx_request_with_message_gets_hx_trigger(self):
|
||||||
|
"""
|
||||||
|
Regression test: vanilla fetch() requests that set Django messages
|
||||||
|
must receive HX-Trigger so fetchWithHtmxTriggers can read them.
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/games/{self.game.id}/status",
|
||||||
|
data=json.dumps({"status": "played"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertIn("HX-Trigger", response)
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertIn("show-toast", data)
|
||||||
|
self.assertEqual(data["show-toast"]["type"], "success")
|
||||||
|
|
||||||
|
def test_session_device_api_endpoint_sends_hx_trigger(self):
|
||||||
|
"""
|
||||||
|
Verify the session device API endpoint also produces HX-Trigger.
|
||||||
|
This is the exact endpoint used by sessiondevice_selector.html.
|
||||||
|
"""
|
||||||
|
device = Device(name="Test Device")
|
||||||
|
device.save()
|
||||||
|
zt = ZoneInfo(settings.TIME_ZONE)
|
||||||
|
session = Session(
|
||||||
|
game=self.game,
|
||||||
|
device=device,
|
||||||
|
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=zt),
|
||||||
|
)
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/session/{session.id}/device",
|
||||||
|
data=json.dumps({"device_id": device.id}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertIn("HX-Trigger", response)
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertIn("show-toast", data)
|
||||||
|
self.assertEqual(data["show-toast"]["message"], "Device updated")
|
||||||
|
|
||||||
|
def test_refund_purchase_returns_updated_row_with_hx_trigger(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Verify the refund endpoint returns the updated row HTML so the page
|
||||||
|
swaps it in place without navigating away (preserving URL/query params).
|
||||||
|
"""
|
||||||
|
purchase = Purchase.objects.create(
|
||||||
|
date_purchased=datetime(2023, 1, 1),
|
||||||
|
platform=Platform.objects.first() or pl,
|
||||||
|
)
|
||||||
|
purchase.games.set([self.game])
|
||||||
|
response = self.client.post(
|
||||||
|
f"/tracker/purchase/{purchase.id}/refund",
|
||||||
|
data={"set_abandoned": ""},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn("HX-Redirect", response)
|
||||||
|
self.assertIn("HX-Trigger", response)
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertIn("show-toast", data)
|
||||||
|
self.assertEqual(data["show-toast"]["message"], "Purchase refunded")
|
||||||
|
# Verify the row HTML contains the updated row id
|
||||||
|
body = response.content.decode()
|
||||||
|
self.assertIn(f'purchase-row-{purchase.id}', body)
|
||||||
|
# Verify OoO modal close element
|
||||||
|
self.assertIn('hx-swap-oob', body)
|
||||||
|
self.assertIn('refund-confirmation-modal', body)
|
||||||
|
# Verify the purchase is actually refunded
|
||||||
|
purchase.refresh_from_db()
|
||||||
|
self.assertIsNotNone(purchase.date_refunded)
|
||||||
@@ -1,14 +1,10 @@
|
|||||||
import os
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import django
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.contrib.auth.models import User
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
|
||||||
django.setup()
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from games.models import Game, Platform, Purchase, Session
|
from games.models import Game, Platform, Purchase, Session
|
||||||
|
|
||||||
@@ -17,67 +13,50 @@ ZONEINFO = ZoneInfo(settings.TIME_ZONE)
|
|||||||
|
|
||||||
class PathWorksTest(TestCase):
|
class PathWorksTest(TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
pl = Platform(name="Test Platform")
|
self.user = User.objects.create_superuser(
|
||||||
pl.save()
|
username="testuser", email="test@example.com", password="testpass"
|
||||||
g = Game(name="The Test Game")
|
)
|
||||||
g.save()
|
self.client.force_login(self.user)
|
||||||
p = Purchase(
|
self.platform = Platform.objects.create(name="Test Platform", icon="test")
|
||||||
games=[e],
|
self.game = Game.objects.create(name="Test Game", platform=self.platform)
|
||||||
platform=pl,
|
self.purchase = Purchase.objects.create(
|
||||||
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||||
|
platform=self.platform,
|
||||||
)
|
)
|
||||||
p.save()
|
self.purchase.games.add(self.game)
|
||||||
s = Session(
|
|
||||||
purchase=p,
|
|
||||||
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
|
||||||
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
|
|
||||||
)
|
|
||||||
s.save()
|
|
||||||
self.testSession = s
|
|
||||||
return super().setUp()
|
|
||||||
|
|
||||||
def test_add_device_returns_200(self):
|
def test_index_redirects_to_tracker(self):
|
||||||
url = reverse("add_device")
|
response = self.client.get("/")
|
||||||
response = self.client.get(url)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_tracker_page_returns_200(self):
|
||||||
|
response = self.client.get("/tracker/", follow=True)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_add_platform_returns_200(self):
|
def test_game_list_returns_200(self):
|
||||||
url = reverse("add_platform")
|
response = self.client.get(reverse("games:list_games"), follow=True)
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_add_game_returns_200(self):
|
|
||||||
url = reverse("add_game")
|
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_add_purchase_returns_200(self):
|
|
||||||
url = reverse("add_purchase")
|
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_add_session_returns_200(self):
|
|
||||||
url = reverse("add_session")
|
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_edit_session_returns_200(self):
|
|
||||||
id = self.testSession.id
|
|
||||||
url = reverse("edit_session", args=[id])
|
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_view_game_returns_200(self):
|
def test_view_game_returns_200(self):
|
||||||
url = reverse("view_game", args=[1])
|
response = self.client.get(reverse("games:view_game", args=[self.game.id]))
|
||||||
response = self.client.get(url)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_edit_game_returns_200(self):
|
def test_add_game_returns_200(self):
|
||||||
url = reverse("edit_game", args=[1])
|
response = self.client.get(reverse("games:add_game"))
|
||||||
response = self.client.get(url)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_stats_returns_200(self):
|
||||||
|
response = self.client.get(reverse("games:stats_alltime"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_list_sessions_returns_200(self):
|
def test_list_sessions_returns_200(self):
|
||||||
url = reverse("list_sessions")
|
response = self.client.get(reverse("games:list_sessions"))
|
||||||
response = self.client.get(url)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_list_playevents_returns_200(self):
|
||||||
|
response = self.client.get(reverse("games:list_playevents"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_list_purchases_returns_200(self):
|
||||||
|
response = self.client.get(reverse("games:list_purchases"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import os
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import django
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
|
||||||
django.setup()
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
from games.models import Game, Purchase, Session
|
from games.models import Game, Purchase, Session
|
||||||
|
|
||||||
@@ -22,16 +17,18 @@ class FormatDurationTest(TestCase):
|
|||||||
g = Game(name="The Test Game")
|
g = Game(name="The Test Game")
|
||||||
g.save()
|
g.save()
|
||||||
p = Purchase(
|
p = Purchase(
|
||||||
game=g, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
|
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
|
||||||
)
|
)
|
||||||
p.save()
|
p.save()
|
||||||
|
p.games.add(g)
|
||||||
|
p.save()
|
||||||
s = Session(
|
s = Session(
|
||||||
purchase=p,
|
game=g,
|
||||||
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
|
||||||
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
|
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
|
||||||
)
|
)
|
||||||
s.save()
|
s.save()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
s.duration_formatted(),
|
s.duration_formatted(),
|
||||||
"02:40",
|
"2.7",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import os
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import django
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
|
|
||||||
django.setup()
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
from games.models import Game, Session
|
from games.models import Game, Session
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from common.time import daterange, streak_bruteforce
|
|||||||
|
|
||||||
|
|
||||||
class StreakTest(unittest.TestCase):
|
class StreakTest(unittest.TestCase):
|
||||||
streak = streak_bruteforce
|
|
||||||
|
|
||||||
def test_daterange_exclusive(self):
|
def test_daterange_exclusive(self):
|
||||||
d = daterange(date(2024, 8, 1), date(2024, 8, 3))
|
d = daterange(date(2024, 8, 1), date(2024, 8, 3))
|
||||||
@@ -22,14 +21,14 @@ class StreakTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_1day_streak(self):
|
def test_1day_streak(self):
|
||||||
self.assertEqual(streak([date(2024, 8, 1)])["days"], 1)
|
self.assertEqual(streak_bruteforce([date(2024, 8, 1)])["days"], 1)
|
||||||
|
|
||||||
def test_2day_streak(self):
|
def test_2day_streak(self):
|
||||||
self.assertEqual(streak([date(2024, 8, 1), date(2024, 8, 2)])["days"], 2)
|
self.assertEqual(streak_bruteforce([date(2024, 8, 1), date(2024, 8, 2)])["days"], 2)
|
||||||
|
|
||||||
def test_31day_streak(self):
|
def test_31day_streak(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
streak(daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True))[
|
streak_bruteforce(daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True))[
|
||||||
"days"
|
"days"
|
||||||
],
|
],
|
||||||
31,
|
31,
|
||||||
@@ -39,14 +38,14 @@ class StreakTest(unittest.TestCase):
|
|||||||
d = daterange(
|
d = daterange(
|
||||||
date(2024, 8, 1), date(2024, 8, 5), end_inclusive=True
|
date(2024, 8, 1), date(2024, 8, 5), end_inclusive=True
|
||||||
) + daterange(date(2024, 8, 7), date(2024, 8, 10), end_inclusive=True)
|
) + daterange(date(2024, 8, 7), date(2024, 8, 10), end_inclusive=True)
|
||||||
self.assertEqual(streak(d)["days"], 5)
|
self.assertEqual(streak_bruteforce(d)["days"], 5)
|
||||||
|
|
||||||
def test_10day_streak_in_31_days(self):
|
def test_10day_streak_in_31_days(self):
|
||||||
d = daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True)
|
d = daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True)
|
||||||
d.remove(date(2024, 8, 8))
|
d.remove(date(2024, 8, 8))
|
||||||
d.remove(date(2024, 8, 15))
|
d.remove(date(2024, 8, 15))
|
||||||
d.remove(date(2024, 8, 21))
|
d.remove(date(2024, 8, 21))
|
||||||
self.assertEqual(streak(d)["days"], 10)
|
self.assertEqual(streak_bruteforce(d)["days"], 10)
|
||||||
|
|
||||||
def test_10day_streak_in_31_days_with_consecutive_missing(self):
|
def test_10day_streak_in_31_days_with_consecutive_missing(self):
|
||||||
d = daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True)
|
d = daterange(date(2024, 8, 1), date(2024, 8, 31), end_inclusive=True)
|
||||||
@@ -57,4 +56,4 @@ class StreakTest(unittest.TestCase):
|
|||||||
d.remove(date(2024, 8, 8))
|
d.remove(date(2024, 8, 8))
|
||||||
d.remove(date(2024, 8, 15))
|
d.remove(date(2024, 8, 15))
|
||||||
d.remove(date(2024, 8, 21))
|
d.remove(date(2024, 8, 21))
|
||||||
self.assertEqual(streak(d)["days"], 10)
|
self.assertEqual(streak_bruteforce(d)["days"], 10)
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import json
|
||||||
|
from django.contrib.messages import constants as message_constants
|
||||||
|
from django.contrib.messages.storage.fallback import FallbackStorage
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from games.htmx_middleware import HTMXMessagesMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
def get_response_ok(request):
|
||||||
|
return HttpResponse("OK")
|
||||||
|
|
||||||
|
|
||||||
|
class HtmxDetails:
|
||||||
|
boosted = False
|
||||||
|
current_url = ""
|
||||||
|
target_id = ""
|
||||||
|
|
||||||
|
|
||||||
|
class HTMXMessagesMiddlewareTest(TestCase):
|
||||||
|
def _build_request(self, htmx=True, message_level=None):
|
||||||
|
"""Build a request with FallbackStorage message backend."""
|
||||||
|
request = HttpRequest()
|
||||||
|
request.method = "GET"
|
||||||
|
request.path = "/test"
|
||||||
|
request.META = {"SERVER_NAME": "localhost", "SERVER_PORT": "80"}
|
||||||
|
request.session = {}
|
||||||
|
|
||||||
|
storage = FallbackStorage(request)
|
||||||
|
if message_level is not None:
|
||||||
|
storage._set_level(message_level)
|
||||||
|
request._messages = storage
|
||||||
|
|
||||||
|
if htmx:
|
||||||
|
request.htmx = HtmxDetails()
|
||||||
|
|
||||||
|
return request
|
||||||
|
|
||||||
|
def test_htmx_request_with_messages_sends_hx_trigger(self):
|
||||||
|
"""HTMX request with messages should include HX-Trigger header."""
|
||||||
|
request = self._build_request(htmx=True)
|
||||||
|
request._messages.add(message_constants.SUCCESS, "Item saved")
|
||||||
|
middleware = HTMXMessagesMiddleware(get_response_ok)
|
||||||
|
|
||||||
|
response = middleware(request)
|
||||||
|
|
||||||
|
self.assertIn("HX-Trigger", response)
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertIn("show-toast", data)
|
||||||
|
self.assertEqual(data["show-toast"]["message"], "Item saved")
|
||||||
|
self.assertEqual(data["show-toast"]["type"], "success")
|
||||||
|
|
||||||
|
def test_htmx_request_with_error_message(self):
|
||||||
|
"""Error messages should map to 'error' toast type."""
|
||||||
|
request = self._build_request(htmx=True)
|
||||||
|
request._messages.add(message_constants.ERROR, "Something failed")
|
||||||
|
middleware = HTMXMessagesMiddleware(get_response_ok)
|
||||||
|
|
||||||
|
response = middleware(request)
|
||||||
|
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertEqual(data["show-toast"]["type"], "error")
|
||||||
|
|
||||||
|
def test_htmx_request_with_success_message(self):
|
||||||
|
"""Success messages should map to 'success' toast type."""
|
||||||
|
request = self._build_request(htmx=True)
|
||||||
|
request._messages.add(message_constants.SUCCESS, "Saved successfully")
|
||||||
|
middleware = HTMXMessagesMiddleware(get_response_ok)
|
||||||
|
|
||||||
|
response = middleware(request)
|
||||||
|
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertEqual(data["show-toast"]["type"], "success")
|
||||||
|
|
||||||
|
def test_non_htmx_request_also_sends_hx_trigger(self):
|
||||||
|
"""Non-HTMX requests should also include HX-Trigger header."""
|
||||||
|
request = self._build_request(htmx=False)
|
||||||
|
request._messages.add(message_constants.SUCCESS, "Hello")
|
||||||
|
middleware = HTMXMessagesMiddleware(get_response_ok)
|
||||||
|
|
||||||
|
response = middleware(request)
|
||||||
|
|
||||||
|
self.assertIn("HX-Trigger", response)
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertIn("show-toast", data)
|
||||||
|
self.assertEqual(data["show-toast"]["message"], "Hello")
|
||||||
|
|
||||||
|
def test_htmx_request_without_messages_no_hx_trigger(self):
|
||||||
|
"""HTMX request without messages should not include HX-Trigger header."""
|
||||||
|
request = self._build_request(htmx=True)
|
||||||
|
middleware = HTMXMessagesMiddleware(get_response_ok)
|
||||||
|
|
||||||
|
response = middleware(request)
|
||||||
|
|
||||||
|
self.assertNotIn("HX-Trigger", response)
|
||||||
|
|
||||||
|
def test_warning_message_maps_to_warning(self):
|
||||||
|
"""Warning messages should map to 'warning' toast type."""
|
||||||
|
request = self._build_request(htmx=True)
|
||||||
|
request._messages.add(message_constants.WARNING, "Warning message")
|
||||||
|
middleware = HTMXMessagesMiddleware(get_response_ok)
|
||||||
|
|
||||||
|
response = middleware(request)
|
||||||
|
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertEqual(data["show-toast"]["type"], "warning")
|
||||||
|
|
||||||
|
@override_settings(DEBUG=True)
|
||||||
|
def test_debug_message_maps_to_debug(self):
|
||||||
|
"""Debug messages should map to 'debug' toast type."""
|
||||||
|
request = self._build_request(htmx=True, message_level=message_constants.DEBUG)
|
||||||
|
request._messages.add(message_constants.DEBUG, "Debug info")
|
||||||
|
middleware = HTMXMessagesMiddleware(get_response_ok)
|
||||||
|
|
||||||
|
response = middleware(request)
|
||||||
|
|
||||||
|
data = json.loads(response["HX-Trigger"])
|
||||||
|
self.assertEqual(data["show-toast"]["type"], "debug")
|
||||||
@@ -39,7 +39,6 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"template_partials",
|
"template_partials",
|
||||||
"graphene_django",
|
|
||||||
"django_htmx",
|
"django_htmx",
|
||||||
"django_cotton",
|
"django_cotton",
|
||||||
"django_q",
|
"django_q",
|
||||||
@@ -54,8 +53,6 @@ Q_CLUSTER = {
|
|||||||
"orm": "default",
|
"orm": "default",
|
||||||
}
|
}
|
||||||
|
|
||||||
GRAPHENE = {"SCHEMA": "games.schema.schema"}
|
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
INSTALLED_APPS.append("django_extensions")
|
INSTALLED_APPS.append("django_extensions")
|
||||||
INSTALLED_APPS.append("django.contrib.admin")
|
INSTALLED_APPS.append("django.contrib.admin")
|
||||||
@@ -70,6 +67,7 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"django_htmx.middleware.HtmxMiddleware",
|
"django_htmx.middleware.HtmxMiddleware",
|
||||||
|
"games.htmx_middleware.HTMXMessagesMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
|
|||||||
@@ -18,16 +18,13 @@ from django.conf import settings
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
from graphene_django.views import GraphQLView
|
|
||||||
|
|
||||||
from games.api import api
|
from games.api import api
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", RedirectView.as_view(url="/tracker")),
|
path("", RedirectView.as_view(url="/tracker")),
|
||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
|
|
||||||
path("login/", auth_views.LoginView.as_view(), name="login"),
|
path("login/", auth_views.LoginView.as_view(), name="login"),
|
||||||
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||||
path("tracker/", include("games.urls")),
|
path("tracker/", include("games.urls")),
|
||||||
|
|||||||
@@ -0,0 +1,964 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.13, <4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asgiref"
|
||||||
|
version = "3.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfgv"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "croniter"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "pytz" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a7/8c/0656200bfa5c1e90b26f4bb1cc8aecb4a7722f8386ee044bdc2d4efb589e/croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e", size = 57084, upload-time = "2024-10-29T16:30:31.556Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/68/34c3d74d2af6ea98ff8a0b50d149cff26e88a3f09817121d1186e9185e97/croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9", size = 24149, upload-time = "2024-10-29T16:30:29.769Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssbeautifier"
|
||||||
|
version = "1.15.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "editorconfig" },
|
||||||
|
{ name = "jsbeautifier" },
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f7/01/fdf41c1e5f93d359681976ba10410a04b299d248e28ecce1d4e88588dde4/cssbeautifier-1.15.4.tar.gz", hash = "sha256:9bb08dc3f64c101a01677f128acf01905914cf406baf87434dcde05b74c0acf5", size = 25376, upload-time = "2025-02-27T17:53:51.341Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/51/ef6c5628e46092f0a54c7cee69acc827adc6b6aab57b55d344fefbdf28f1/cssbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:78c84d5e5378df7d08622bbd0477a1abdbd209680e95480bf22f12d5701efc98", size = 123667, upload-time = "2025-02-27T17:53:43.594Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "distlib"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django"
|
||||||
|
version = "6.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "sqlparse" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/9b/016f7e55e855ee738a352b05139d4f8b278d0b451bd01ebef07456ef3b0e/django-6.0.1.tar.gz", hash = "sha256:ed76a7af4da21551573b3d9dfc1f53e20dd2e6c7d70a3adc93eedb6338130a5f", size = 11069565, upload-time = "2026-01-06T18:55:53.069Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/b5/814ed98bd21235c116fd3436a7ed44d47560329a6d694ec8aac2982dbb93/django-6.0.1-py3-none-any.whl", hash = "sha256:a92a4ff14f664a896f9849009cb8afaca7abe0d6fc53325f3d1895a15253433d", size = 8338791, upload-time = "2026-01-06T18:55:46.175Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-cotton"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/00/3a/1cc607c2688ac3690775ed918b114de8dd70b6d76a3a328af9dcba1c7c31/django_cotton-2.3.0.tar.gz", hash = "sha256:603bf3a0548c9eb069f4f9d30424bf6ad91a3cebc52c497749a7da4dc4b73426", size = 27450, upload-time = "2025-11-10T07:17:51.45Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/b0/ad797da10f80c58a6c0c1c16502427e23a894c27f0b80ac13a36218c246c/django_cotton-2.3.0-py3-none-any.whl", hash = "sha256:a9cb9669f41e21cc9286128ada4613aaf0e2e9d837cab8da3fdb99cbbe5e78c5", size = 26991, upload-time = "2025-11-10T07:17:50.196Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-debug-toolbar"
|
||||||
|
version = "4.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "sqlparse" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/9c/0a3238eda0a46df20f2e3fe2a30313d34f5042a1a737d08230b77c29a3e9/django_debug_toolbar-4.4.6.tar.gz", hash = "sha256:36e421cb908c2f0675e07f9f41e3d1d8618dc386392ec82d23bcfcd5d29c7044", size = 272610, upload-time = "2024-07-10T13:18:13.302Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/33/2036a472eedfbe49240dffea965242b3f444de4ea4fbeceb82ccea33a2ce/django_debug_toolbar-4.4.6-py3-none-any.whl", hash = "sha256:3beb671c9ec44ffb817fad2780667f172bd1c067dbcabad6268ce39a81335f45", size = 229621, upload-time = "2024-07-10T13:18:35.71Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-extensions"
|
||||||
|
version = "3.2.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8a/f1/318684c9466968bf9a9c221663128206e460c1a67f595055be4b284cde8a/django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a", size = 277216, upload-time = "2023-06-05T17:09:01.447Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/7e/ba12b9660642663f5273141018d2bec0a1cae1711f4f6d1093920e157946/django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401", size = 229868, upload-time = "2023-06-05T17:08:58.197Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-htmx"
|
||||||
|
version = "1.27.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "asgiref" },
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/34/f2/8c3e28a5eed8e5226835c762892bfef74eda7e8629c65b49c186098eb303/django_htmx-1.27.0.tar.gz", hash = "sha256:036e5da801bfdf5f1ca815f21592cfb9f004a898f330c842f15e55c70e301a75", size = 65362, upload-time = "2025-11-28T23:18:55.049Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/ac/25d28489dc43224e260f4ebee7565f7ef1efe12af0f284a89500c19f75e2/django_htmx-1.27.0-py3-none-any.whl", hash = "sha256:13e1e13b87d39b57f95aae6e4987cb3df056d0b1373a41f4a94504a00298ffd8", size = 62126, upload-time = "2025-11-28T23:18:53.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-ninja"
|
||||||
|
version = "1.6.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-picklefield"
|
||||||
|
version = "3.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/93/03/13114bccbd1ec8c026ac1ff33dae75ae6c6a5632e4769ee9cda283b9f57e/django_picklefield-3.4.0.tar.gz", hash = "sha256:3a1f740536c0e60d0dba43aa89ccdbe86760d4c3f8ec47799eae122baa741d0a", size = 12555, upload-time = "2025-11-27T03:11:53.13Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/b7/139eb1419ca7b27fd714925b8d0eed6efb592479dcf2155fed6c0c87c956/django_picklefield-3.4.0-py3-none-any.whl", hash = "sha256:929bcfbae5b48bd22a52bc04521fdfdd152eee36abb9f20228f9480f9df65f45", size = 10031, upload-time = "2025-11-27T03:11:51.937Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-q2"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "django-picklefield" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/e6/21375bed54a4be1339f6ee31e4173d361d457dbe91db7bff130b52566126/django_q2-1.9.0.tar.gz", hash = "sha256:ef7facca96fae9c11ddf2c5252d3817975c7a9a6d989fa0d65487d8823d57799", size = 77218, upload-time = "2025-12-04T22:11:29.336Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/b7/8282f9815fc9df3187d9303a6f54e0388e02742255dee1fed7b4019a03ae/django_q2-1.9.0-py3-none-any.whl", hash = "sha256:4eded27644b0ffb291839c9f9c12fea6c0dec63ebd891fa6881b0b446098a49d", size = 89615, upload-time = "2025-12-04T22:11:28.079Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-template-partials"
|
||||||
|
version = "24.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/ff/2a7ddae12ca8e5fea1a41af05924c04f1bb4aec7157b04a88b829dd93d4a/django_template_partials-24.4.tar.gz", hash = "sha256:25b67301470fc274ecc419e5e5fd4686a5020b1c038fd241a70eb087809034b6", size = 14538, upload-time = "2024-08-16T10:51:30.204Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/72/d8eea70683b25230e0d2647b5cf6f2db4a7e7d35cb6170506d9618196374/django_template_partials-24.4-py2.py3-none-any.whl", hash = "sha256:ee59d3839385d7f648907c3fa8d5923fcd66cd8090f141fe2a1c338b917984e2", size = 8439, upload-time = "2024-08-16T10:51:28.437Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "djhtml"
|
||||||
|
version = "3.0.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/dc/7d2a8e1e2a5054a50c328e02b4704179b80a8fbf0535bde793d85840c669/djhtml-3.0.10.tar.gz", hash = "sha256:dd4ebf778d3b7da7a6e6970f7e66740f08ed7485485491b9a80527f526c838d9", size = 28414, upload-time = "2025-10-08T12:03:05.291Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/236b8a9f28b2fa1301d0b6eb15b4ce86d03215afaa02fe12913003f97103/djhtml-3.0.10-py3-none-any.whl", hash = "sha256:d6efbe6001008d730ede5c21944a427a76c901c6cd168c138e494d2a1091e0b9", size = 26633, upload-time = "2025-10-08T12:03:17.172Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "djlint"
|
||||||
|
version = "1.36.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "colorama" },
|
||||||
|
{ name = "cssbeautifier" },
|
||||||
|
{ name = "jsbeautifier" },
|
||||||
|
{ name = "json5" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "regex" },
|
||||||
|
{ name = "tqdm" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "editorconfig"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/88/3a/a61d9a1f319a186b05d14df17daea42fcddea63c213bcd61a929fb3a6796/editorconfig-0.17.1.tar.gz", hash = "sha256:23c08b00e8e08cc3adcddb825251c497478df1dada6aefeb01e626ad37303745", size = 14695, upload-time = "2025-06-09T08:21:37.097Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/fd/a40c621ff207f3ce8e484aa0fc8ba4eb6e3ecf52e15b42ba764b457a9550/editorconfig-0.17.1-py3-none-any.whl", hash = "sha256:1eda9c2c0db8c16dbd50111b710572a5e6de934e39772de1959d41f64fc17c82", size = 16360, upload-time = "2025-06-09T08:21:35.654Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "3.20.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "23.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "identify"
|
||||||
|
version = "2.6.16"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "isort"
|
||||||
|
version = "5.13.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303, upload-time = "2023-12-13T20:37:26.124Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsbeautifier"
|
||||||
|
version = "1.15.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "editorconfig" },
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ea/98/d6cadf4d5a1c03b2136837a435682418c29fdeb66be137128544cecc5b7a/jsbeautifier-1.15.4.tar.gz", hash = "sha256:5bb18d9efb9331d825735fbc5360ee8f1aac5e52780042803943aa7f854f7592", size = 75257, upload-time = "2025-02-27T17:53:53.252Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/14/1c65fccf8413d5f5c6e8425f84675169654395098000d8bddc4e9d3390e1/jsbeautifier-1.15.4-py3-none-any.whl", hash = "sha256:72f65de312a3f10900d7685557f84cb61a9733c50dcc27271a39f5b0051bf528", size = 94707, upload-time = "2025-02-27T17:53:46.152Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "json5"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "librt"
|
||||||
|
version = "0.7.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown"
|
||||||
|
version = "3.10.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy"
|
||||||
|
version = "1.19.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodeenv"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pre-commit"
|
||||||
|
version = "3.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cfgv" },
|
||||||
|
{ name = "identify" },
|
||||||
|
{ name = "nodeenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "virtualenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815, upload-time = "2024-07-28T19:59:01.538Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643, upload-time = "2024-07-28T19:58:59.335Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.12.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.41.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-django"
|
||||||
|
version = "4.12.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytz"
|
||||||
|
version = "2025.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "2026.1.15"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.14.14"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparse"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "timetracker"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "croniter" },
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "django-cotton" },
|
||||||
|
{ name = "django-htmx" },
|
||||||
|
{ name = "django-ninja" },
|
||||||
|
{ name = "django-q2" },
|
||||||
|
{ name = "django-template-partials" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
|
{ name = "markdown" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "django-debug-toolbar" },
|
||||||
|
{ name = "django-extensions" },
|
||||||
|
{ name = "djhtml" },
|
||||||
|
{ name = "djlint" },
|
||||||
|
{ name = "isort" },
|
||||||
|
{ name = "mypy" },
|
||||||
|
{ name = "pre-commit" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-django" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "croniter", specifier = ">=5.0.1,<6" },
|
||||||
|
{ name = "django", specifier = ">6.0" },
|
||||||
|
{ name = "django-cotton", specifier = "==2.3" },
|
||||||
|
{ name = "django-htmx", specifier = ">=1.18.0,<2" },
|
||||||
|
{ name = "django-ninja", specifier = ">=1.6.2" },
|
||||||
|
{ name = "django-q2", specifier = ">=1.7.4,<2" },
|
||||||
|
{ name = "django-template-partials", specifier = ">=24.2,<25" },
|
||||||
|
{ name = "gunicorn", specifier = ">=23.0.0,<24" },
|
||||||
|
{ name = "markdown", specifier = ">=3.6,<4" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.2,<7" },
|
||||||
|
{ name = "requests", specifier = ">=2.32.3,<3" },
|
||||||
|
{ name = "uvicorn", specifier = ">=0.30.1,<0.31" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "django-debug-toolbar", specifier = ">=4.4.2,<5" },
|
||||||
|
{ name = "django-extensions", specifier = ">=3.2.3,<4" },
|
||||||
|
{ name = "djhtml", specifier = ">=3.0.6,<4" },
|
||||||
|
{ name = "djlint", specifier = ">=1.34.1,<2" },
|
||||||
|
{ name = "isort", specifier = ">=5.13.2,<6" },
|
||||||
|
{ name = "mypy", specifier = ">=1.10.1,<2" },
|
||||||
|
{ name = "pre-commit", specifier = ">=3.7.1,<4" },
|
||||||
|
{ name = "pytest", specifier = ">=8.2.2,<9" },
|
||||||
|
{ name = "pytest-django", specifier = ">=4.12.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.1,<7" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tqdm"
|
||||||
|
version = "4.67.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2025.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
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" }
|
||||||
|
wheels = [
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uvicorn"
|
||||||
|
version = "0.30.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/01/5e637e7aa9dd031be5376b9fb749ec20b86f5a5b6a49b87fabd374d5fa9f/uvicorn-0.30.6.tar.gz", hash = "sha256:4b15decdda1e72be08209e860a1e10e92439ad5b97cf44cc945fcbee66fc5788", size = 42825, upload-time = "2024-08-13T09:27:35.098Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/8e/cdc7d6263db313030e4c257dd5ba3909ebc4e4fb53ad62d5f09b1a2f5458/uvicorn-0.30.6-py3-none-any.whl", hash = "sha256:65fd46fe3fda5bdc1b03b94eb634923ff18cd35b2f084813ea79d1f103f711b5", size = 62835, upload-time = "2024-08-13T09:27:33.536Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "virtualenv"
|
||||||
|
version = "20.36.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "distlib" },
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user