Remove bloat
This commit is contained in:
parent
011c617328
commit
715cbf4bf0
51 changed files with 691 additions and 3032 deletions
115
.github/copilot-instructions.md
vendored
115
.github/copilot-instructions.md
vendored
|
|
@ -1,115 +0,0 @@
|
||||||
# TTVDrops AI Coding Agent Instructions
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
TTVDrops is a Django web application that tracks Twitch drop campaigns and sends notifications when new drops become available. It processes Twitch API data to monitor gaming drops, campaigns, and user subscriptions.
|
|
||||||
|
|
||||||
## Architecture & Key Components
|
|
||||||
|
|
||||||
### Core Django Structure
|
|
||||||
- **Config Package**: `config/` - Django settings, URLs, WSGI configuration
|
|
||||||
- **Twitch App**: `twitch/` - Main application handling drop campaigns, games, organizations
|
|
||||||
- **Accounts App**: `accounts/` - Custom user model extending Django's AbstractUser
|
|
||||||
- **Templates**: `templates/` - Django templates with base layout and app-specific views
|
|
||||||
- **Static Files**: `staticfiles/` - Collected static assets including admin and debug toolbar assets
|
|
||||||
|
|
||||||
### Data Model Hierarchy
|
|
||||||
The application follows a specific data flow: Organization → Game → DropCampaign → TimeBasedDrop → DropBenefit
|
|
||||||
|
|
||||||
Key models in `twitch/models.py`:
|
|
||||||
- `Organization`: Twitch organizations that own games
|
|
||||||
- `Game`: Games on Twitch with drop campaigns
|
|
||||||
- `DropCampaign`: Individual drop campaigns with start/end dates
|
|
||||||
- `TimeBasedDrop`: Time-based drops within campaigns (require watching X minutes)
|
|
||||||
- `DropBenefit`: Rewards earned from drops (connected via `DropBenefitEdge`)
|
|
||||||
- `NotificationSubscription`: User subscriptions to games/organizations for notifications
|
|
||||||
|
|
||||||
### Key Data Processing Patterns
|
|
||||||
|
|
||||||
#### JSON Import System
|
|
||||||
The project heavily uses the `import_drops.py` management command to process Twitch API responses:
|
|
||||||
- Handles various JSON response structures from Twitch GraphQL API
|
|
||||||
- Automatically categorizes and moves processed files to subdirectories
|
|
||||||
- Uses `json_repair` library for malformed JSON
|
|
||||||
- Implements transaction-based imports with error handling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Import from single file or directory
|
|
||||||
uv run python manage.py import_drops path/to/json/file_or_directory
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Development Workflow
|
|
||||||
Always use `uv` for dependency management and Python execution:
|
|
||||||
```bash
|
|
||||||
uv run python manage.py runserver # Start development server
|
|
||||||
uv run python manage.py migrate # Apply migrations
|
|
||||||
uv run python manage.py makemigrations # Create new migrations
|
|
||||||
uv run pytest # Run tests
|
|
||||||
uv run python manage.py import_drops responses/ # Import Twitch data
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project-Specific Conventions
|
|
||||||
|
|
||||||
### Code Style & Linting
|
|
||||||
- **Ruff**: Comprehensive linting with extensive rule set (see `pyproject.toml`)
|
|
||||||
- **Type Annotations**: All functions require type hints with `from __future__ import annotations`
|
|
||||||
- **Google Docstring Style**: Use Google-style docstrings for functions/classes
|
|
||||||
- **Django Stubs**: mypy integration for Django type checking
|
|
||||||
|
|
||||||
### Database Configuration
|
|
||||||
- **SQLite with WAL mode**: Optimized SQLite configuration in `settings.py`
|
|
||||||
- **Custom data directory**: Uses `platformdirs` for OS-appropriate data storage
|
|
||||||
- **Timezone-aware**: All datetime fields use Django's timezone utilities
|
|
||||||
|
|
||||||
### Testing Setup
|
|
||||||
- **pytest-django**: Test configuration in `conftest.py` and `pyproject.toml`
|
|
||||||
- **Parallel execution**: Tests run with `pytest-xdist` using `--reuse-db --no-migrations`
|
|
||||||
- **Fast password hashing**: MD5 hasher for tests performance
|
|
||||||
|
|
||||||
## Important Development Notes
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
Required environment variables (use `.env` file):
|
|
||||||
- `DJANGO_SECRET_KEY`: Django secret key (required)
|
|
||||||
- `DEBUG`: Boolean for debug mode (default: "True")
|
|
||||||
- Email settings: `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, etc.
|
|
||||||
|
|
||||||
### File Processing Patterns
|
|
||||||
When working with the import system:
|
|
||||||
- JSON files are automatically categorized and moved to `processed/` subdirectories
|
|
||||||
- Failed imports go to specific directories: `broken/`, `actual_error/`, `we_should_double_check/`
|
|
||||||
- The system handles multiple Twitch API response formats and GraphQL operation types
|
|
||||||
|
|
||||||
### Model Relationships
|
|
||||||
- Games can have multiple drop campaigns
|
|
||||||
- Drop campaigns contain time-based drops that unlock benefits
|
|
||||||
- Users can subscribe to games/organizations for notifications
|
|
||||||
- Organizations own games (nullable relationship)
|
|
||||||
|
|
||||||
### RSS Feeds
|
|
||||||
The application provides RSS feeds for organizations, games, and campaigns via `twitch/feeds.py`
|
|
||||||
|
|
||||||
### Development Tools
|
|
||||||
- **Debug Toolbar**: Enabled in development for database query analysis
|
|
||||||
- **Browser Reload**: Automatic page refresh during development
|
|
||||||
- **Django Extensions**: Consider using for shell_plus and other utilities
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
- Always use timezone-aware datetime objects with `django.utils.timezone`
|
|
||||||
- Import command requires specific JSON structures - check existing patterns in `import_drops.py`
|
|
||||||
- Model field updates should filter out None values to avoid overwriting existing data
|
|
||||||
- Use `update_or_create()` pattern consistently for data imports to handle duplicates
|
|
||||||
|
|
||||||
## Quick Reference Commands
|
|
||||||
```bash
|
|
||||||
# Development setup
|
|
||||||
uv run python manage.py createsuperuser
|
|
||||||
uv run python manage.py collectstatic
|
|
||||||
|
|
||||||
# Data processing
|
|
||||||
uv run python manage.py import_drops responses/
|
|
||||||
uv run python manage.py import_drops single_file.json
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
uv run pytest -v # Verbose test output
|
|
||||||
uv run pytest twitch/tests/ # Test specific app
|
|
||||||
```
|
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -8,9 +8,12 @@
|
||||||
"ASGI",
|
"ASGI",
|
||||||
"collectstatic",
|
"collectstatic",
|
||||||
"createsuperuser",
|
"createsuperuser",
|
||||||
|
"dateparser",
|
||||||
|
"djlint",
|
||||||
"docstrings",
|
"docstrings",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"Hellsén",
|
"Hellsén",
|
||||||
|
"httpx",
|
||||||
"IGDB",
|
"IGDB",
|
||||||
"isort",
|
"isort",
|
||||||
"Joakim",
|
"Joakim",
|
||||||
|
|
@ -22,6 +25,7 @@
|
||||||
"prefetcher",
|
"prefetcher",
|
||||||
"psutil",
|
"psutil",
|
||||||
"pydocstyle",
|
"pydocstyle",
|
||||||
|
"pygments",
|
||||||
"pyright",
|
"pyright",
|
||||||
"pytest",
|
"pytest",
|
||||||
"Ravendawn",
|
"Ravendawn",
|
||||||
|
|
|
||||||
674
LICENSE
674
LICENSE
|
|
@ -1,674 +0,0 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 29 June 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
|
||||||
or can get the source code. And you must show them these terms so they
|
|
||||||
know their rights.
|
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
|
||||||
that there is no warranty for this free software. For both users' and
|
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
|
||||||
changed, so that their problems will not be attributed erroneously to
|
|
||||||
authors of previous versions.
|
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the special requirements of the GNU Affero General Public License,
|
|
||||||
section 13, concerning interaction through a network will apply to the
|
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU General Public License from time to time. Such new versions will
|
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
|
||||||
notice like this when it starts in an interactive mode:
|
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
|
||||||
This is free software, and you are welcome to redistribute it
|
|
||||||
under certain conditions; type `show c' for details.
|
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.contrib.auth.admin import UserAdmin
|
|
||||||
|
|
||||||
from accounts.models import User
|
|
||||||
|
|
||||||
admin.site.register(User, UserAdmin)
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AccountsConfig(AppConfig):
|
|
||||||
"""Configuration for the accounts app."""
|
|
||||||
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "accounts"
|
|
||||||
verbose_name = "Accounts"
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.contrib.auth.forms import UserCreationForm
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
|
|
||||||
from accounts.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class CustomUserCreationForm(UserCreationForm):
|
|
||||||
"""Custom user creation form for the custom User model."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
fields = ("username",)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
|
||||||
"""Initialize form with Bootstrap classes."""
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
# Add Bootstrap classes to form fields
|
|
||||||
for field in self.fields.values():
|
|
||||||
field.widget.attrs.update({"class": "form-control"})
|
|
||||||
|
|
||||||
def clean_username(self) -> str:
|
|
||||||
"""Validate the username using the correct User model.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The cleaned username.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValidationError: If the username already exists.
|
|
||||||
"""
|
|
||||||
username = self.cleaned_data.get("username")
|
|
||||||
if username and User.objects.filter(username=username).exists():
|
|
||||||
msg = "A user with that username already exists."
|
|
||||||
raise ValidationError(msg)
|
|
||||||
return username or ""
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-08-06 23:01
|
|
||||||
|
|
||||||
import django.contrib.auth.models
|
|
||||||
import django.contrib.auth.validators
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('auth', '0012_alter_user_first_name_max_length'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='User',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
|
||||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
|
||||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
|
||||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
|
||||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
|
||||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
|
||||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
|
||||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
|
||||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
|
||||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
|
||||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'User',
|
|
||||||
'verbose_name_plural': 'Users',
|
|
||||||
'db_table': 'auth_user',
|
|
||||||
},
|
|
||||||
managers=[
|
|
||||||
('objects', django.contrib.auth.models.UserManager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
|
||||||
"""Custom User model extending Django's AbstractUser.
|
|
||||||
|
|
||||||
This allows for future customization of the User model
|
|
||||||
while maintaining all the default Django User functionality.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
db_table = "auth_user" # Keep the same table name as Django's default User
|
|
||||||
verbose_name = "User"
|
|
||||||
verbose_name_plural = "Users"
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.urls import path
|
|
||||||
|
|
||||||
from accounts import views
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from django.urls.resolvers import URLPattern
|
|
||||||
|
|
||||||
app_name = "accounts"
|
|
||||||
|
|
||||||
urlpatterns: list[URLPattern] = [
|
|
||||||
path("login/", views.CustomLoginView.as_view(), name="login"),
|
|
||||||
path("logout/", views.CustomLogoutView.as_view(), name="logout"),
|
|
||||||
path("signup/", views.SignUpView.as_view(), name="signup"),
|
|
||||||
path("profile/", views.profile_view, name="profile"),
|
|
||||||
]
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.contrib.auth import login
|
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.contrib.auth.views import LoginView, LogoutView
|
|
||||||
from django.shortcuts import render
|
|
||||||
from django.urls import reverse_lazy
|
|
||||||
from django.views.generic import CreateView
|
|
||||||
|
|
||||||
from accounts.forms import CustomUserCreationForm
|
|
||||||
from accounts.models import User
|
|
||||||
from twitch.models import Game, NotificationSubscription
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from django.forms import BaseModelForm
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
|
||||||
|
|
||||||
|
|
||||||
class CustomLoginView(LoginView):
|
|
||||||
"""Custom login view with better styling."""
|
|
||||||
|
|
||||||
template_name = "accounts/login.html"
|
|
||||||
redirect_authenticated_user = True
|
|
||||||
|
|
||||||
def get_success_url(self) -> str:
|
|
||||||
"""Redirect to the dashboard after successful login.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: URL to redirect to after successful login.
|
|
||||||
"""
|
|
||||||
return reverse_lazy("twitch:dashboard") # pyright: ignore[reportReturnType]
|
|
||||||
|
|
||||||
|
|
||||||
class CustomLogoutView(LogoutView):
|
|
||||||
"""Custom logout view."""
|
|
||||||
|
|
||||||
next_page = reverse_lazy("twitch:dashboard") # pyright: ignore[reportAssignmentType]
|
|
||||||
http_method_names = ["get", "post", "options"]
|
|
||||||
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
|
||||||
"""Allow GET requests for logout.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The HTTP request object.
|
|
||||||
*args: Additional positional arguments.
|
|
||||||
**kwargs: Additional keyword arguments.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HttpResponse: Response after logout.
|
|
||||||
"""
|
|
||||||
return self.post(request, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class SignUpView(CreateView):
|
|
||||||
"""User registration view."""
|
|
||||||
|
|
||||||
model = User
|
|
||||||
form_class = CustomUserCreationForm
|
|
||||||
template_name = "accounts/signup.html"
|
|
||||||
success_url = reverse_lazy("twitch:dashboard")
|
|
||||||
|
|
||||||
def form_valid(self, form: BaseModelForm) -> HttpResponse:
|
|
||||||
"""Login the user after successful registration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
form: The validated user creation form.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HttpResponse: Response after successful form processing.
|
|
||||||
"""
|
|
||||||
response = super().form_valid(form)
|
|
||||||
login(self.request, self.object) # type: ignore[attr-defined]
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def profile_view(request: HttpRequest) -> HttpResponse:
|
|
||||||
"""User profile view.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The HTTP request object.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
HttpResponse: Rendered profile template.
|
|
||||||
"""
|
|
||||||
subscriptions = NotificationSubscription.objects.filter(user=request.user) # type: ignore[misc]
|
|
||||||
|
|
||||||
# Direct game subscriptions
|
|
||||||
direct_game_subscriptions = subscriptions.filter(game_id__isnull=False)
|
|
||||||
|
|
||||||
# Organization subscriptions
|
|
||||||
org_subscriptions = subscriptions.filter(organization_id__isnull=False)
|
|
||||||
|
|
||||||
# Get games from organization subscriptions (inherited games)
|
|
||||||
inherited_games = Game.objects.filter(owner__in=org_subscriptions.values("organization"))
|
|
||||||
|
|
||||||
# Create mapping of inherited game IDs to their organization names
|
|
||||||
inherited_game_org_map = {}
|
|
||||||
for game in inherited_games:
|
|
||||||
if game.owner:
|
|
||||||
inherited_game_org_map[game.id] = game.owner.name
|
|
||||||
|
|
||||||
# Combine direct and inherited game IDs
|
|
||||||
direct_game_ids = direct_game_subscriptions.values_list("game_id", flat=True)
|
|
||||||
inherited_game_ids = inherited_games.values_list("id", flat=True)
|
|
||||||
all_game_ids = set(direct_game_ids) | set(inherited_game_ids)
|
|
||||||
|
|
||||||
# Get all games (direct + inherited)
|
|
||||||
game_subscriptions_combined = Game.objects.filter(id__in=all_game_ids)
|
|
||||||
|
|
||||||
# Get direct game IDs for template indication
|
|
||||||
direct_game_ids = list(direct_game_subscriptions.values_list("game_id", flat=True))
|
|
||||||
|
|
||||||
# Get all games (direct + inherited) with inheritance info
|
|
||||||
games_with_inheritance = []
|
|
||||||
for game in game_subscriptions_combined:
|
|
||||||
is_inherited = game.id not in direct_game_ids
|
|
||||||
inherited_from = inherited_game_org_map.get(game.id) if is_inherited else None
|
|
||||||
games_with_inheritance.append({
|
|
||||||
"game": game,
|
|
||||||
"is_inherited": is_inherited,
|
|
||||||
"inherited_from": inherited_from,
|
|
||||||
})
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"accounts/profile.html",
|
|
||||||
{
|
|
||||||
"user": request.user,
|
|
||||||
"games_with_inheritance": games_with_inheritance,
|
|
||||||
"org_subscriptions": org_subscriptions,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
23
compose.yml
23
compose.yml
|
|
@ -1,23 +0,0 @@
|
||||||
services:
|
|
||||||
ttvdrops_db:
|
|
||||||
image: postgres:18beta3
|
|
||||||
container_name: ttvdrops_db
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ttvdrops
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?You must set POSTGRES_PASSWORD}
|
|
||||||
POSTGRES_DB: ttvdrops
|
|
||||||
PGDATA: /data
|
|
||||||
command: postgres -c config_file=/config/postgresql.conf
|
|
||||||
shm_size: 5g
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- /mnt/Docker/Data/ttvdrops/postgresql/data:/data
|
|
||||||
- /mnt/Docker/Data/ttvdrops/postgresql/config:/config
|
|
||||||
networks:
|
|
||||||
- ttvdrops
|
|
||||||
|
|
||||||
networks:
|
|
||||||
ttvdrops:
|
|
||||||
driver: bridge
|
|
||||||
|
|
@ -41,7 +41,6 @@ def get_data_dir() -> Path:
|
||||||
DATA_DIR: Path = get_data_dir()
|
DATA_DIR: Path = get_data_dir()
|
||||||
|
|
||||||
ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")]
|
ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")]
|
||||||
AUTH_USER_MODEL = "accounts.User"
|
|
||||||
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
BASE_DIR: Path = Path(__file__).resolve().parent.parent
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|
@ -112,13 +111,8 @@ LOGGING: dict[str, Any] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
INSTALLED_APPS: list[str] = [
|
INSTALLED_APPS: list[str] = [
|
||||||
"django.contrib.admin",
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
|
||||||
"django.contrib.messages",
|
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"accounts.apps.AccountsConfig",
|
|
||||||
"twitch.apps.TwitchConfig",
|
"twitch.apps.TwitchConfig",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -127,8 +121,6 @@ MIDDLEWARE: list[str] = [
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -139,58 +131,35 @@ TEMPLATES: list[dict[str, str | list[Path] | bool | dict[str, list[str] | list[t
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.template.context_processors.i18n",
|
|
||||||
"django.template.context_processors.debug",
|
"django.template.context_processors.debug",
|
||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# PostgreSQL configuration (preferred when env vars provided)
|
# https://blog.pecar.me/django-sqlite-benchmark
|
||||||
DATABASES: dict[str, dict[str, Any]] = { # pyright: ignore[reportInvalidTypeForm]
|
DATABASES: dict[str, dict[str, str | Path | dict[str, str]]] = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": os.getenv("POSTGRES_DB"),
|
"NAME": DATA_DIR / "ttvdrops.sqlite3",
|
||||||
"USER": os.getenv("POSTGRES_USER", "ttvdrops"),
|
"OPTIONS": {
|
||||||
"PASSWORD": os.getenv("POSTGRES_PASSWORD", "ttvdrops"),
|
"init_command": "PRAGMA foreign_keys = ON; PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA mmap_size = 134217728; PRAGMA journal_size_limit = 27103364; PRAGMA cache_size=2000;", # noqa: E501
|
||||||
"HOST": os.getenv("POSTGRES_HOST", "localhost"),
|
"transaction_mode": "IMMEDIATE",
|
||||||
"PORT": os.getenv("POSTGRES_PORT", "5432"),
|
},
|
||||||
"CONN_MAX_AGE": 60,
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS: list[dict[str, str]] = [
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
TESTING: bool = "test" in sys.argv or "PYTEST_VERSION" in os.environ
|
TESTING: bool = "test" in sys.argv or "PYTEST_VERSION" in os.environ
|
||||||
|
|
||||||
if not TESTING:
|
if not TESTING:
|
||||||
DEBUG_TOOLBAR_CONFIG: dict[str, str] = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"}
|
DEBUG_TOOLBAR_CONFIG: dict[str, str] = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"}
|
||||||
INSTALLED_APPS = [ # pyright: ignore[reportConstantRedefinition]
|
INSTALLED_APPS = [ # pyright: ignore[reportConstantRedefinition]
|
||||||
"django_watchfiles",
|
|
||||||
*INSTALLED_APPS,
|
*INSTALLED_APPS,
|
||||||
"debug_toolbar",
|
"debug_toolbar",
|
||||||
"django_browser_reload",
|
|
||||||
]
|
]
|
||||||
MIDDLEWARE = [ # pyright: ignore[reportConstantRedefinition]
|
MIDDLEWARE = [ # pyright: ignore[reportConstantRedefinition]
|
||||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||||
*MIDDLEWARE,
|
*MIDDLEWARE,
|
||||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,12 @@ from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.urls.resolvers import URLPattern, URLResolver
|
from django.urls.resolvers import URLPattern, URLResolver
|
||||||
|
|
||||||
urlpatterns: list[URLResolver] | list[URLPattern | URLResolver] = [ # type: ignore[assignment]
|
urlpatterns: list[URLResolver] | list[URLPattern | URLResolver] = [ # type: ignore[assignment]
|
||||||
path(route="admin/", view=admin.site.urls),
|
|
||||||
path(route="accounts/", view=include("accounts.urls", namespace="accounts")),
|
|
||||||
path(route="", view=include("twitch.urls", namespace="twitch")),
|
path(route="", view=include("twitch.urls", namespace="twitch")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -23,7 +20,6 @@ if not settings.TESTING:
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
*urlpatterns,
|
*urlpatterns,
|
||||||
*debug_toolbar_urls(),
|
*debug_toolbar_urls(),
|
||||||
path("__reload__/", include("django_browser_reload.urls")),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serve media in development
|
# Serve media in development
|
||||||
|
|
|
||||||
23
conftest.py
23
conftest.py
|
|
@ -1,23 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import django
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.auth.models import update_last_login
|
|
||||||
from django.contrib.auth.signals import user_logged_in
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure() -> None:
|
|
||||||
"""Configure Django settings for pytest."""
|
|
||||||
if not settings.configured:
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
|
||||||
django.setup()
|
|
||||||
|
|
||||||
# Use faster password hasher for tests
|
|
||||||
settings.PASSWORD_HASHERS = [
|
|
||||||
"django.contrib.auth.hashers.MD5PasswordHasher",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Disconnect update_last_login signal to avoid unnecessary DB writes
|
|
||||||
user_logged_in.disconnect(update_last_login)
|
|
||||||
|
|
@ -6,29 +6,23 @@ readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dateparser>=1.2.2",
|
"dateparser>=1.2.2",
|
||||||
"django-browser-reload>=1.18.0",
|
|
||||||
"django-debug-toolbar>=5.2.0",
|
"django-debug-toolbar>=5.2.0",
|
||||||
"django-stubs[compatible-mypy]>=5.2.2",
|
|
||||||
"django-watchfiles>=1.1.0",
|
|
||||||
"django>=5.2.4",
|
"django>=5.2.4",
|
||||||
"djlint>=1.36.4",
|
"djlint>=1.36.4",
|
||||||
"json-repair>=0.50.0",
|
"json-repair>=0.50.0",
|
||||||
"orjson>=3.11.1",
|
|
||||||
"platformdirs>=4.3.8",
|
"platformdirs>=4.3.8",
|
||||||
"python-dotenv>=1.1.1",
|
"python-dotenv>=1.1.1",
|
||||||
"psycopg[binary]>=3.2.3",
|
|
||||||
"pygments>=2.19.2",
|
"pygments>=2.19.2",
|
||||||
"django-auto-prefetch>=1.13.0",
|
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8.4.1", "pytest-django>=4.11.1", "pytest-xdist[psutil]>=3.8.0"]
|
dev = ["pytest>=8.4.1", "pytest-django>=4.11.1"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE = "config.settings"
|
DJANGO_SETTINGS_MODULE = "config.settings"
|
||||||
python_files = ["test_*.py", "*_test.py"]
|
python_files = ["test_*.py", "*_test.py"]
|
||||||
addopts = ["-n", "auto", "--reuse-db", "--no-migrations"]
|
addopts = ["--reuse-db", "--no-migrations"]
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
exclude = ["**/migrations/**"]
|
exclude = ["**/migrations/**"]
|
||||||
|
|
@ -96,9 +90,3 @@ line-length = 160
|
||||||
[tool.djlint]
|
[tool.djlint]
|
||||||
profile = "django"
|
profile = "django"
|
||||||
ignore = "H021"
|
ignore = "H021"
|
||||||
|
|
||||||
[tool.mypy]
|
|
||||||
plugins = ["mypy_django_plugin.main"]
|
|
||||||
|
|
||||||
[tool.django-stubs]
|
|
||||||
django_settings_module = "config.settings"
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}
|
|
||||||
Login
|
|
||||||
{% endblock title %}
|
|
||||||
{% block content %}
|
|
||||||
<h4 id="page-title">Login</h4>
|
|
||||||
{% if form.errors %}
|
|
||||||
<ul id="error-list">
|
|
||||||
{% for field, errors in form.errors.items %}
|
|
||||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
<form id="login-form" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label for="{{ form.username.id_for_label }}">Username</label>
|
|
||||||
<input type="text"
|
|
||||||
name="username"
|
|
||||||
id="{{ form.username.id_for_label }}"
|
|
||||||
value="{{ form.username.value|default:'' }}"
|
|
||||||
required>
|
|
||||||
<label for="{{ form.password.id_for_label }}">Password</label>
|
|
||||||
<input type="password"
|
|
||||||
name="password"
|
|
||||||
id="{{ form.password.id_for_label }}"
|
|
||||||
required>
|
|
||||||
<button id="login-button" type="submit">Login</button>
|
|
||||||
</form>
|
|
||||||
<p>
|
|
||||||
Don't have an account? <a id="signup-link" href="{% url 'accounts:signup' %}">Sign up here</a>
|
|
||||||
</p>
|
|
||||||
<style>
|
|
||||||
.form-control {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-group-text {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock content %}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}
|
|
||||||
{{ user.username }}
|
|
||||||
{% endblock title %}
|
|
||||||
{% block content %}
|
|
||||||
<h2 id="username">{{ user.username }}</h2>
|
|
||||||
<p>
|
|
||||||
Joined <time id="date-joined">{{ user.date_joined|date:"F d, Y" }}</time>
|
|
||||||
</p>
|
|
||||||
<table id="user-info-table">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>Date Joined:</strong>
|
|
||||||
</td>
|
|
||||||
<td>{{ user.date_joined|date:"F d, Y" }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>Last Login:</strong>
|
|
||||||
</td>
|
|
||||||
<td>{{ user.last_login|date:"F d, Y H:i"|default:"Never" }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<strong>Email:</strong>
|
|
||||||
</td>
|
|
||||||
<td>{{ user.email|default:"Not provided" }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<a id="logout-link" href="{% url 'accounts:logout' %}">Logout</a>
|
|
||||||
<h2>Will get notifications for these subscriptions:</h2>
|
|
||||||
<h3 id="games-subscriptions-header">Games</h3>
|
|
||||||
<ul id="games-subscriptions-list">
|
|
||||||
{% for item in games_with_inheritance %}
|
|
||||||
<li id="game-subscription-{{ item.game.id }}">
|
|
||||||
<a href="{% url 'twitch:game_detail' item.game.id %}">{{ item.game.display_name }}</a>
|
|
||||||
{% if item.is_inherited %}
|
|
||||||
<span style="font-size: 0.85em; color: #666; font-style: italic;">(inherited from {{ item.inherited_from }})</span>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% empty %}
|
|
||||||
<li>You have no game subscriptions yet.</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
<h3 id="org-subscriptions-header">Organizations</h3>
|
|
||||||
<ul id="org-subscriptions-list">
|
|
||||||
{% for subscription in org_subscriptions %}
|
|
||||||
<li id="org-subscription-{{ subscription.organization_id }}">
|
|
||||||
<a href="{% url 'twitch:organization_detail' subscription.organization_id %}">{{ subscription.organization.name }}</a>
|
|
||||||
</li>
|
|
||||||
{% empty %}
|
|
||||||
<li>You have no organization subscriptions yet.</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endblock content %}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}
|
|
||||||
Sign Up
|
|
||||||
{% endblock title %}
|
|
||||||
{% block content %}
|
|
||||||
<h4 id="page-title">Sign Up</h4>
|
|
||||||
{% if form.errors %}
|
|
||||||
<ul id="error-list">
|
|
||||||
{% for field, errors in form.errors.items %}
|
|
||||||
{% for error in errors %}<li>{{ error }}</li>{% endfor %}
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
<form id="signup-form" method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
<label for="{{ form.username.id_for_label }}">Username</label>
|
|
||||||
<input type="text"
|
|
||||||
name="username"
|
|
||||||
id="{{ form.username.id_for_label }}"
|
|
||||||
value="{{ form.username.value|default:'' }}"
|
|
||||||
required>
|
|
||||||
{% if form.username.help_text %}<small id="username-help">{{ form.username.help_text }}</small>{% endif %}
|
|
||||||
<label for="{{ form.password1.id_for_label }}">Password</label>
|
|
||||||
<input type="password"
|
|
||||||
name="password1"
|
|
||||||
id="{{ form.password1.id_for_label }}"
|
|
||||||
required>
|
|
||||||
{% if form.password1.help_text %}<small id="password1-help">{{ form.password1.help_text }}</small>{% endif %}
|
|
||||||
<label for="{{ form.password2.id_for_label }}">Confirm Password</label>
|
|
||||||
<input type="password"
|
|
||||||
name="password2"
|
|
||||||
id="{{ form.password2.id_for_label }}"
|
|
||||||
required>
|
|
||||||
{% if form.password2.help_text %}<small id="password2-help">{{ form.password2.help_text }}</small>{% endif %}
|
|
||||||
<button id="signup-button" type="submit">Sign Up</button>
|
|
||||||
</form>
|
|
||||||
<p>
|
|
||||||
Already have an account? <a id="login-link" href="{% url 'accounts:login' %}">Login here</a>
|
|
||||||
</p>
|
|
||||||
{% endblock content %}
|
|
||||||
|
|
@ -79,17 +79,7 @@
|
||||||
<a href="{% url 'twitch:org_list' %}">Organizations</a> |
|
<a href="{% url 'twitch:org_list' %}">Organizations</a> |
|
||||||
<a href="{% url 'twitch:channel_list' %}">Channels</a> |
|
<a href="{% url 'twitch:channel_list' %}">Channels</a> |
|
||||||
<a href="{% url 'twitch:docs_rss' %}">RSS</a> |
|
<a href="{% url 'twitch:docs_rss' %}">RSS</a> |
|
||||||
{% if user.is_authenticated %}
|
|
||||||
<a href="{% url 'twitch:debug' %}">Debug</a> |
|
<a href="{% url 'twitch:debug' %}">Debug</a> |
|
||||||
{% if user.is_staff %}
|
|
||||||
<a href="{% url 'admin:index' %}">Admin</a> |
|
|
||||||
{% endif %}
|
|
||||||
<a href="{% url 'accounts:profile' %}">{{ user.username }}</a>
|
|
||||||
{% else %}
|
|
||||||
<a href="{% url 'accounts:login' %}">Login</a> |
|
|
||||||
<a href="{% url 'accounts:signup' %}">Sign Up</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
|
||||||
<form action="{% url 'twitch:search' %}"
|
<form action="{% url 'twitch:search' %}"
|
||||||
method="get"
|
method="get"
|
||||||
style="display: inline">
|
style="display: inline">
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<!-- Campaign Title -->
|
<!-- Campaign Title -->
|
||||||
{% if campaign.game %}
|
{% if campaign.game %}
|
||||||
<h1 id="campaign-title">
|
<h1 id="campaign-title">
|
||||||
<a href="{% url 'twitch:game_detail' campaign.game.id %}">{{ campaign.game.name }}</a> - {{ campaign.clean_name }}
|
<a href="{% url 'twitch:game_detail' campaign.game.id %}">{{ campaign.game.get_game_name }}</a> - {{ campaign.clean_name }}
|
||||||
</h1>
|
</h1>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1 id="campaign-title">{{ campaign.clean_name }}</h1>
|
<h1 id="campaign-title">{{ campaign.clean_name }}</h1>
|
||||||
|
|
@ -29,19 +29,35 @@
|
||||||
<p id="campaign-description">{{ campaign.description|linebreaksbr }}</p>
|
<p id="campaign-description">{{ campaign.description|linebreaksbr }}</p>
|
||||||
<!-- Campaign end times -->
|
<!-- Campaign end times -->
|
||||||
<div>
|
<div>
|
||||||
|
{% if campaign.end_at < now %}
|
||||||
|
<time id="campaign-end-time"
|
||||||
|
datetime="{{ campaign.end_at|date:'c' }}"
|
||||||
|
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">
|
||||||
|
<strong>Ended</strong> {{ campaign.end_at|timesince }} ago
|
||||||
|
</time>
|
||||||
|
{% else %}
|
||||||
<time id="campaign-end-time"
|
<time id="campaign-end-time"
|
||||||
datetime="{{ campaign.end_at|date:'c' }}"
|
datetime="{{ campaign.end_at|date:'c' }}"
|
||||||
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">
|
title="{{ campaign.end_at|date:'DATETIME_FORMAT' }}">
|
||||||
<strong>Ends in</strong> {{ campaign.end_at|timeuntil }}
|
<strong>Ends in</strong> {{ campaign.end_at|timeuntil }}
|
||||||
</time>
|
</time>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Campaign start times -->
|
<!-- Campaign start times -->
|
||||||
<div>
|
<div>
|
||||||
|
{% if campaign.start_at > now %}
|
||||||
|
<time id="campaign-start-time"
|
||||||
|
datetime="{{ campaign.start_at|date:'c' }}"
|
||||||
|
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">
|
||||||
|
<strong>Starts in</strong> {{ campaign.start_at|timeuntil }}
|
||||||
|
</time>
|
||||||
|
{% else %}
|
||||||
<time id="campaign-start-time"
|
<time id="campaign-start-time"
|
||||||
datetime="{{ campaign.start_at|date:'c' }}"
|
datetime="{{ campaign.start_at|date:'c' }}"
|
||||||
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">
|
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }}">
|
||||||
<strong>Started</strong> {{ campaign.start_at|timesince }} ago
|
<strong>Started</strong> {{ campaign.start_at|timesince }} ago
|
||||||
</time>
|
</time>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Campaign added times -->
|
<!-- Campaign added times -->
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -56,7 +72,7 @@
|
||||||
<time id="campaign-duration"
|
<time id="campaign-duration"
|
||||||
datetime="{{ campaign.start_at|date:'c' }} to {{ campaign.end_at|date:'c' }}"
|
datetime="{{ campaign.start_at|date:'c' }} to {{ campaign.end_at|date:'c' }}"
|
||||||
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}">
|
title="{{ campaign.start_at|date:'DATETIME_FORMAT' }} to {{ campaign.end_at|date:'DATETIME_FORMAT' }}">
|
||||||
<strong>Duration</strong> {{ campaign.start_at|timesince:campaign.end_at }} ago
|
<strong>Duration</strong> {{ campaign.start_at|timesince:campaign.end_at }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
<!-- Campaign Detail URL -->
|
<!-- Campaign Detail URL -->
|
||||||
|
|
@ -117,20 +133,13 @@
|
||||||
<tr id="drop-{{ drop.drop.id }}">
|
<tr id="drop-{{ drop.drop.id }}">
|
||||||
<td>
|
<td>
|
||||||
{% for benefit in drop.drop.benefits.all %}
|
{% for benefit in drop.drop.benefits.all %}
|
||||||
{% if benefit.image_best_url or benefit.image_asset_url %}
|
{% if benefit.image_asset_url %}
|
||||||
<img height="160"
|
<img height="160"
|
||||||
width="160"
|
width="160"
|
||||||
style="object-fit: cover;
|
style="object-fit: cover;
|
||||||
margin-right: 3px"
|
margin-right: 3px"
|
||||||
src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
|
src="{{ benefit.image_best_url|default:benefit.image_asset_url }}"
|
||||||
alt="{{ benefit.name }}">
|
alt="{{ benefit.name }}">
|
||||||
{% else %}
|
|
||||||
<img height="160"
|
|
||||||
width="160"
|
|
||||||
style="object-fit: cover;
|
|
||||||
margin-right: 3px"
|
|
||||||
src="{% static 'images/placeholder.png' %}"
|
|
||||||
alt="No Image Available">
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -53,8 +53,8 @@
|
||||||
style="margin-bottom: 3rem">
|
style="margin-bottom: 3rem">
|
||||||
<div style="display: flex; gap: 1rem;">
|
<div style="display: flex; gap: 1rem;">
|
||||||
<div style="flex-shrink: 0;">
|
<div style="flex-shrink: 0;">
|
||||||
{% if game_group.grouper.box_art_best_url %}
|
{% if game_group.grouper.box_art_base_url %}
|
||||||
<img src="{{ game_group.grouper.box_art_best_url }}"
|
<img src="{{ game_group.grouper.box_art_base_url }}"
|
||||||
alt="Box art for {{ game_group.grouper.display_name }}"
|
alt="Box art for {{ game_group.grouper.display_name }}"
|
||||||
width="120"
|
width="120"
|
||||||
height="160"
|
height="160"
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ Hover over the end time to see the exact date and time.
|
||||||
flex-shrink: 0">
|
flex-shrink: 0">
|
||||||
<div>
|
<div>
|
||||||
<a href="{% url 'twitch:campaign_detail' campaign.id %}">
|
<a href="{% url 'twitch:campaign_detail' campaign.id %}">
|
||||||
<img src="{{ campaign.image_best_url|default:campaign.image_url }}"
|
<img src="{{ campaign.image_url }}"
|
||||||
alt="Image for {{ campaign.name }}"
|
alt="Image for {{ campaign.name }}"
|
||||||
width="120"
|
width="120"
|
||||||
height="120"
|
height="120"
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@
|
||||||
{% if game.display_name != game.name and game.name %}<small>({{ game.name }})</small>{% endif %}
|
{% if game.display_name != game.name and game.name %}<small>({{ game.name }})</small>{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
<!-- Game image -->
|
<!-- Game image -->
|
||||||
{% if game.box_art_best_url %}
|
{% if game.box_art %}
|
||||||
<img id="game-image"
|
<img id="game-image"
|
||||||
height="160"
|
height="160"
|
||||||
width="160"
|
width="160"
|
||||||
src="{{ game.box_art_best_url }}"
|
src="{{ game.box_art }}"
|
||||||
alt="{{ game.name }}">
|
alt="{{ game.name }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- Game owner -->
|
<!-- Game owner -->
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@
|
||||||
flex: 1 1 160px;
|
flex: 1 1 160px;
|
||||||
text-align: center">
|
text-align: center">
|
||||||
<div style="margin-bottom: 0.25rem;">
|
<div style="margin-bottom: 0.25rem;">
|
||||||
{% if item.game.box_art_best_url %}
|
{% if item.game.box_art_base_url %}
|
||||||
<img src="{{ item.game.box_art_best_url }}"
|
<img src="{{ item.game.box_art_base_url }}"
|
||||||
alt="Box art for {{ item.game.display_name }}"
|
alt="Box art for {{ item.game.display_name }}"
|
||||||
width="180"
|
width="180"
|
||||||
height="240"
|
height="240"
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
from twitch.models import DropBenefit, DropBenefitEdge, DropCampaign, Game, Organization, TimeBasedDrop
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: Game
|
|
||||||
@admin.register(Game)
|
|
||||||
class GameAdmin(admin.ModelAdmin):
|
|
||||||
"""Admin configuration for Game model."""
|
|
||||||
|
|
||||||
list_display = ("id", "display_name", "slug")
|
|
||||||
search_fields = ("id", "display_name", "slug")
|
|
||||||
readonly_fields = ("added_at", "updated_at")
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: Organization
|
|
||||||
@admin.register(Organization)
|
|
||||||
class OrganizationAdmin(admin.ModelAdmin):
|
|
||||||
"""Admin configuration for Organization model."""
|
|
||||||
|
|
||||||
list_display = ("id", "name")
|
|
||||||
search_fields = ("id", "name")
|
|
||||||
readonly_fields = ("added_at", "updated_at")
|
|
||||||
|
|
||||||
|
|
||||||
class TimeBasedDropInline(admin.TabularInline):
|
|
||||||
"""Inline admin for TimeBasedDrop model."""
|
|
||||||
|
|
||||||
model = TimeBasedDrop
|
|
||||||
extra = 0
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: DropCampaign
|
|
||||||
@admin.register(DropCampaign)
|
|
||||||
class DropCampaignAdmin(admin.ModelAdmin):
|
|
||||||
"""Admin configuration for DropCampaign model."""
|
|
||||||
|
|
||||||
list_display = ("id", "name", "game", "start_at", "end_at", "is_active")
|
|
||||||
list_filter = ("game",)
|
|
||||||
search_fields = ("id", "name", "description")
|
|
||||||
inlines = [TimeBasedDropInline]
|
|
||||||
readonly_fields = ("added_at", "updated_at")
|
|
||||||
|
|
||||||
|
|
||||||
class DropBenefitEdgeInline(admin.TabularInline):
|
|
||||||
"""Inline admin for DropBenefitEdge model."""
|
|
||||||
|
|
||||||
model = DropBenefitEdge
|
|
||||||
extra = 0
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: TimeBasedDrop
|
|
||||||
@admin.register(TimeBasedDrop)
|
|
||||||
class TimeBasedDropAdmin(admin.ModelAdmin):
|
|
||||||
"""Admin configuration for TimeBasedDrop model."""
|
|
||||||
|
|
||||||
list_display = (
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"campaign",
|
|
||||||
"required_minutes_watched",
|
|
||||||
"required_subs",
|
|
||||||
"start_at",
|
|
||||||
"end_at",
|
|
||||||
)
|
|
||||||
list_filter = ("campaign__game", "campaign")
|
|
||||||
readonly_fields = ("added_at", "updated_at")
|
|
||||||
|
|
||||||
search_fields = ("id", "name")
|
|
||||||
inlines = [DropBenefitEdgeInline]
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: DropBenefit
|
|
||||||
@admin.register(DropBenefit)
|
|
||||||
class DropBenefitAdmin(admin.ModelAdmin):
|
|
||||||
"""Admin configuration for DropBenefit model."""
|
|
||||||
|
|
||||||
list_display = (
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"distribution_type",
|
|
||||||
"entitlement_limit",
|
|
||||||
"created_at",
|
|
||||||
)
|
|
||||||
list_filter = ("distribution_type",)
|
|
||||||
search_fields = ("id", "name")
|
|
||||||
readonly_fields = ("added_at", "updated_at")
|
|
||||||
|
|
@ -2,25 +2,24 @@ from __future__ import annotations
|
||||||
|
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import timedelta
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import dateparser
|
import dateparser
|
||||||
import json_repair
|
import json_repair
|
||||||
|
from django.core.exceptions import MultipleObjectsReturned
|
||||||
from django.core.management.base import BaseCommand, CommandError, CommandParser
|
from django.core.management.base import BaseCommand, CommandError, CommandParser
|
||||||
from django.db import transaction
|
from django.db import DatabaseError, IntegrityError, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
from twitch.models import Channel, DropBenefit, DropBenefitEdge, DropCampaign, Game, Organization, TimeBasedDrop
|
from twitch.models import Channel, DropBenefit, DropBenefitEdge, DropCampaign, Game, Organization, TimeBasedDrop
|
||||||
from twitch.utils.images import cache_remote_image
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=4096)
|
@lru_cache(maxsize=4096)
|
||||||
|
|
@ -58,6 +57,22 @@ class Command(BaseCommand):
|
||||||
help = "Import Twitch drop campaign data from a JSON file or directory"
|
help = "Import Twitch drop campaign data from a JSON file or directory"
|
||||||
requires_migrations_checks = True
|
requires_migrations_checks = True
|
||||||
|
|
||||||
|
# In-memory caches
|
||||||
|
_game_cache: dict[str, Game] = {}
|
||||||
|
_organization_cache: dict[str, Organization] = {}
|
||||||
|
_drop_campaign_cache: dict[str, DropCampaign] = {}
|
||||||
|
_channel_cache: dict[str, Channel] = {}
|
||||||
|
_benefit_cache: dict[str, DropBenefit] = {}
|
||||||
|
|
||||||
|
# Locks for thread-safety
|
||||||
|
_cache_locks: dict[str, threading.RLock] = {
|
||||||
|
"game": threading.RLock(),
|
||||||
|
"org": threading.RLock(),
|
||||||
|
"campaign": threading.RLock(),
|
||||||
|
"channel": threading.RLock(),
|
||||||
|
"benefit": threading.RLock(),
|
||||||
|
}
|
||||||
|
|
||||||
def add_arguments(self, parser: CommandParser) -> None:
|
def add_arguments(self, parser: CommandParser) -> None:
|
||||||
"""Add command arguments.
|
"""Add command arguments.
|
||||||
|
|
||||||
|
|
@ -81,6 +96,11 @@ class Command(BaseCommand):
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Continue processing if an error occurs.",
|
help="Continue processing if an error occurs.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-preload",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not preload existing DB objects into memory (default: preload).",
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, **options) -> None:
|
def handle(self, **options) -> None:
|
||||||
"""Execute the command.
|
"""Execute the command.
|
||||||
|
|
@ -89,17 +109,40 @@ class Command(BaseCommand):
|
||||||
**options: Arbitrary keyword arguments.
|
**options: Arbitrary keyword arguments.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
CommandError: If the file/directory doesn't exist, isn't a JSON file,
|
CommandError: If a critical error occurs and --continue-on-error is not set.
|
||||||
or has an invalid JSON structure.
|
ValueError: If the input data is invalid.
|
||||||
ValueError: If the JSON file has an invalid structure.
|
TypeError: If the input data is of an unexpected type.
|
||||||
TypeError: If the JSON file has an invalid JSON structure.
|
AttributeError: If expected attributes are missing in the data.
|
||||||
AttributeError: If the JSON file has an invalid JSON structure.
|
KeyError: If expected keys are missing in the data.
|
||||||
KeyError: If the JSON file has an invalid JSON structure.
|
IndexError: If list indices are out of range in the data.
|
||||||
IndexError: If the JSON file has an invalid JSON structure.
|
|
||||||
"""
|
"""
|
||||||
paths: list[str] = options["paths"]
|
paths: list[str] = options["paths"]
|
||||||
processed_dir: str = options["processed_dir"]
|
processed_dir: str = options["processed_dir"]
|
||||||
continue_on_error: bool = options["continue_on_error"]
|
continue_on_error: bool = options["continue_on_error"]
|
||||||
|
no_preload: bool = options.get("no_preload", False)
|
||||||
|
|
||||||
|
# Preload DB objects into caches (unless disabled)
|
||||||
|
if not no_preload:
|
||||||
|
try:
|
||||||
|
self.stdout.write("Preloading existing database objects into memory...")
|
||||||
|
self._preload_caches()
|
||||||
|
self.stdout.write(
|
||||||
|
f"Preloaded {len(self._game_cache)} games, "
|
||||||
|
f"{len(self._organization_cache)} orgs, "
|
||||||
|
f"{len(self._drop_campaign_cache)} campaigns, "
|
||||||
|
f"{len(self._channel_cache)} channels, "
|
||||||
|
f"{len(self._benefit_cache)} benefits."
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, OSError, RuntimeError):
|
||||||
|
# If preload fails for any reason, continue without it
|
||||||
|
self.stdout.write(self.style.WARNING("Preloading caches failed — continuing without preload."))
|
||||||
|
self.stdout.write(self.style.ERROR(traceback.format_exc()))
|
||||||
|
self._game_cache = {}
|
||||||
|
self._organization_cache = {}
|
||||||
|
self._drop_campaign_cache = {}
|
||||||
|
self._channel_cache = {}
|
||||||
|
self._benefit_cache = {}
|
||||||
|
|
||||||
for p in paths:
|
for p in paths:
|
||||||
try:
|
try:
|
||||||
|
|
@ -129,6 +172,20 @@ class Command(BaseCommand):
|
||||||
self.stdout.write(self.style.WARNING("Interrupted by user, exiting import."))
|
self.stdout.write(self.style.WARNING("Interrupted by user, exiting import."))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _preload_caches(self) -> None:
|
||||||
|
"""Load existing DB objects into in-memory caches to avoid repeated queries."""
|
||||||
|
# These queries may be heavy if DB is huge — safe because optional via --no-preload
|
||||||
|
with self._cache_locks["game"]:
|
||||||
|
self._game_cache = {str(g.id): g for g in Game.objects.all()}
|
||||||
|
with self._cache_locks["org"]:
|
||||||
|
self._organization_cache = {str(o.id): o for o in Organization.objects.all()}
|
||||||
|
with self._cache_locks["campaign"]:
|
||||||
|
self._drop_campaign_cache = {str(c.id): c for c in DropCampaign.objects.all()}
|
||||||
|
with self._cache_locks["channel"]:
|
||||||
|
self._channel_cache = {str(ch.id): ch for ch in Channel.objects.all()}
|
||||||
|
with self._cache_locks["benefit"]:
|
||||||
|
self._benefit_cache = {str(b.id): b for b in DropBenefit.objects.all()}
|
||||||
|
|
||||||
def process_drops(self, *, continue_on_error: bool, path: Path, processed_path: Path) -> None:
|
def process_drops(self, *, continue_on_error: bool, path: Path, processed_path: Path) -> None:
|
||||||
"""Process drops from a file or directory.
|
"""Process drops from a file or directory.
|
||||||
|
|
||||||
|
|
@ -138,8 +195,7 @@ class Command(BaseCommand):
|
||||||
processed_path: Name of subdirectory to move processed files to.
|
processed_path: Name of subdirectory to move processed files to.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
CommandError: If the file/directory doesn't exist, isn't a JSON file,
|
CommandError: If the path is neither a file nor a directory.
|
||||||
or has an invalid JSON structure.
|
|
||||||
"""
|
"""
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
self._process_file(file_path=path, processed_path=processed_path)
|
self._process_file(file_path=path, processed_path=processed_path)
|
||||||
|
|
@ -170,18 +226,18 @@ class Command(BaseCommand):
|
||||||
"""Process all JSON files in a directory using parallel processing.
|
"""Process all JSON files in a directory using parallel processing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
directory: Path to the directory containing JSON files.
|
directory: The directory containing JSON files.
|
||||||
processed_path: Path to the subdirectory where processed files will be moved.
|
processed_path: Name of subdirectory to move processed files to.
|
||||||
continue_on_error: Whether to continue processing remaining files if an error occurs.
|
continue_on_error: Continue processing if an error occurs.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
CommandError: If the path is invalid or moving files fails.
|
AttributeError: If expected attributes are missing in the data.
|
||||||
ValueError: If a JSON file has an invalid structure.
|
CommandError: If a critical error occurs and --continue-on-error is not set.
|
||||||
TypeError: If a JSON file has an invalid structure.
|
IndexError: If list indices are out of range in the data.
|
||||||
AttributeError: If a JSON file has an invalid structure.
|
KeyboardInterrupt: If the process is interrupted by the user.
|
||||||
KeyError: If a JSON file has an invalid structure.
|
KeyError: If expected keys are missing in the data.
|
||||||
IndexError: If a JSON file has an invalid structure.
|
TypeError: If the input data is of an unexpected type.
|
||||||
KeyboardInterrupt: If processing is interrupted by the user.
|
ValueError: If the input data is invalid.
|
||||||
"""
|
"""
|
||||||
json_files: list[Path] = list(directory.glob("*.json"))
|
json_files: list[Path] = list(directory.glob("*.json"))
|
||||||
if not json_files:
|
if not json_files:
|
||||||
|
|
@ -190,51 +246,39 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
total_files: int = len(json_files)
|
total_files: int = len(json_files)
|
||||||
self.stdout.write(f"Found {total_files} JSON files to process")
|
self.stdout.write(f"Found {total_files} JSON files to process")
|
||||||
start_time: float = time.time()
|
|
||||||
processed = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
try:
|
||||||
future_to_file: dict[concurrent.futures.Future[None], Path] = {
|
future_to_file: dict[concurrent.futures.Future[None], Path] = {
|
||||||
executor.submit(self._process_file, json_file, processed_path): json_file for json_file in json_files
|
executor.submit(self._process_file, json_file, processed_path): json_file for json_file in json_files
|
||||||
}
|
}
|
||||||
for future in concurrent.futures.as_completed(future_to_file):
|
# Wrap the as_completed iterator with tqdm for a progress bar
|
||||||
|
for future in tqdm(concurrent.futures.as_completed(future_to_file), total=total_files, desc="Processing files"):
|
||||||
json_file: Path = future_to_file[future]
|
json_file: Path = future_to_file[future]
|
||||||
self.stdout.write(f"Processing file {json_file.name}...")
|
|
||||||
try:
|
try:
|
||||||
future.result()
|
future.result()
|
||||||
except CommandError as e:
|
except CommandError as e:
|
||||||
if not continue_on_error:
|
if not continue_on_error:
|
||||||
|
# To stop all processing, we shut down the executor and re-raise
|
||||||
|
executor.shutdown(wait=False, cancel_futures=True)
|
||||||
raise
|
raise
|
||||||
self.stdout.write(self.style.ERROR(f"Error processing {json_file}: {e}"))
|
self.stdout.write(self.style.ERROR(f"Error processing {json_file}: {e}"))
|
||||||
except (ValueError, TypeError, AttributeError, KeyError, IndexError):
|
except (ValueError, TypeError, AttributeError, KeyError, IndexError):
|
||||||
if not continue_on_error:
|
if not continue_on_error:
|
||||||
|
# To stop all processing, we shut down the executor and re-raise
|
||||||
|
executor.shutdown(wait=False, cancel_futures=True)
|
||||||
raise
|
raise
|
||||||
self.stdout.write(self.style.ERROR(f"Data error processing {json_file}"))
|
self.stdout.write(self.style.ERROR(f"Data error processing {json_file}"))
|
||||||
self.stdout.write(self.style.ERROR(traceback.format_exc()))
|
self.stdout.write(self.style.ERROR(traceback.format_exc()))
|
||||||
|
|
||||||
self.update_processing_progress(total_files=total_files, start_time=start_time, processed=processed)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
self.stdout.write(self.style.WARNING("Interrupted by user, exiting import."))
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
msg: str = f"Processed {total_files} JSON files in {directory}. Moved processed files to {processed_path}."
|
msg: str = f"Processed {total_files} JSON files in {directory}. Moved processed files to {processed_path}."
|
||||||
self.stdout.write(self.style.SUCCESS(msg))
|
self.stdout.write(self.style.SUCCESS(msg))
|
||||||
|
|
||||||
def update_processing_progress(self, total_files: int, start_time: float, processed: int) -> None:
|
except KeyboardInterrupt:
|
||||||
"""Update and display processing progress.
|
self.stdout.write(self.style.WARNING("Interruption received, shutting down threads immediately..."))
|
||||||
|
executor.shutdown(wait=False, cancel_futures=True)
|
||||||
Args:
|
# Re-raise the exception to allow the main `handle` method to catch it and exit
|
||||||
total_files: Total number of files to process.
|
raise
|
||||||
start_time: Timestamp when processing started.
|
|
||||||
processed: Number of files processed so far.
|
|
||||||
"""
|
|
||||||
processed += 1
|
|
||||||
elapsed: float = time.time() - start_time
|
|
||||||
rate: float | Literal[0] = processed / elapsed if elapsed > 0 else 0
|
|
||||||
remaining: int = total_files - processed
|
|
||||||
eta: timedelta = timedelta(seconds=int(remaining / rate)) if rate > 0 else timedelta(seconds=0)
|
|
||||||
self.stdout.write(f"Progress: {processed}/{total_files} files - {rate:.2f} files/sec - ETA {eta}")
|
|
||||||
|
|
||||||
def _process_file(self, file_path: Path, processed_path: Path) -> None:
|
def _process_file(self, file_path: Path, processed_path: Path) -> None:
|
||||||
"""Process a single JSON file.
|
"""Process a single JSON file.
|
||||||
|
|
@ -276,7 +320,7 @@ class Command(BaseCommand):
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
self.move_file(file_path, target_dir / file_path.name)
|
self.move_file(file_path, target_dir / file_path.name)
|
||||||
self.stdout.write(f"Moved {file_path} to {target_dir} (matched '{keyword}')")
|
tqdm.write(f"Moved {file_path} to {target_dir} (matched '{keyword}')")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Some responses have errors:
|
# Some responses have errors:
|
||||||
|
|
@ -286,7 +330,7 @@ class Command(BaseCommand):
|
||||||
actual_error_dir: Path = processed_path / "actual_error"
|
actual_error_dir: Path = processed_path / "actual_error"
|
||||||
actual_error_dir.mkdir(parents=True, exist_ok=True)
|
actual_error_dir.mkdir(parents=True, exist_ok=True)
|
||||||
self.move_file(file_path, actual_error_dir / file_path.name)
|
self.move_file(file_path, actual_error_dir / file_path.name)
|
||||||
self.stdout.write(f"Moved {file_path} to {actual_error_dir} (contains Twitch errors)")
|
tqdm.write(f"Moved {file_path} to {actual_error_dir} (contains Twitch errors)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# If file has "__typename": "BroadcastSettings" move it to the "broadcast_settings" directory
|
# If file has "__typename": "BroadcastSettings" move it to the "broadcast_settings" directory
|
||||||
|
|
@ -305,13 +349,13 @@ class Command(BaseCommand):
|
||||||
and data["data"]["channel"]["viewerDropCampaigns"] is None
|
and data["data"]["channel"]["viewerDropCampaigns"] is None
|
||||||
):
|
):
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
self.stdout.write(f"Removed {file_path} (only contains empty viewerDropCampaigns)")
|
tqdm.write(f"Removed {file_path} (only contains empty viewerDropCampaigns)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# If file only contains {"data": {"user": null}} remove the file
|
# If file only contains {"data": {"user": null}} remove the file
|
||||||
if isinstance(data, dict) and data.get("data", {}).keys() == {"user"} and data["data"]["user"] is None:
|
if isinstance(data, dict) and data.get("data", {}).keys() == {"user"} and data["data"]["user"] is None:
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
self.stdout.write(f"Removed {file_path} (only contains empty user)")
|
tqdm.write(f"Removed {file_path} (only contains empty user)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# If file only contains {"data": {"game": {}}} remove the file
|
# If file only contains {"data": {"game": {}}} remove the file
|
||||||
|
|
@ -319,7 +363,7 @@ class Command(BaseCommand):
|
||||||
game_data = data["data"]["game"]
|
game_data = data["data"]["game"]
|
||||||
if isinstance(game_data, dict) and game_data.get("__typename") == "Game":
|
if isinstance(game_data, dict) and game_data.get("__typename") == "Game":
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
self.stdout.write(f"Removed {file_path} (only contains game data)")
|
tqdm.write(f"Removed {file_path} (only contains game data)")
|
||||||
return
|
return
|
||||||
|
|
||||||
# If file has "__typename": "DropCurrentSession" move it to the "drop_current_session" directory so we can process it separately.
|
# If file has "__typename": "DropCurrentSession" move it to the "drop_current_session" directory so we can process it separately.
|
||||||
|
|
@ -338,7 +382,7 @@ class Command(BaseCommand):
|
||||||
and data[0]["data"]["user"] is None
|
and data[0]["data"]["user"] is None
|
||||||
):
|
):
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
self.stdout.write(f"Removed {file_path} (list with one item: empty user)")
|
tqdm.write(f"Removed {file_path} (list with one item: empty user)")
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
|
|
@ -363,6 +407,7 @@ class Command(BaseCommand):
|
||||||
shutil.move(str(file_path), str(processed_path))
|
shutil.move(str(file_path), str(processed_path))
|
||||||
except FileExistsError:
|
except FileExistsError:
|
||||||
# Rename the file if contents is different than the existing one
|
# Rename the file if contents is different than the existing one
|
||||||
|
try:
|
||||||
with (
|
with (
|
||||||
file_path.open("rb") as f1,
|
file_path.open("rb") as f1,
|
||||||
(processed_path / file_path.name).open("rb") as f2,
|
(processed_path / file_path.name).open("rb") as f2,
|
||||||
|
|
@ -370,23 +415,20 @@ class Command(BaseCommand):
|
||||||
if f1.read() != f2.read():
|
if f1.read() != f2.read():
|
||||||
new_name: Path = processed_path / f"{file_path.stem}_duplicate{file_path.suffix}"
|
new_name: Path = processed_path / f"{file_path.stem}_duplicate{file_path.suffix}"
|
||||||
shutil.move(str(file_path), str(new_name))
|
shutil.move(str(file_path), str(new_name))
|
||||||
self.stdout.write(f"Moved {file_path!s} to {new_name!s} (content differs)")
|
tqdm.write(f"Moved {file_path!s} to {new_name!s} (content differs)")
|
||||||
else:
|
else:
|
||||||
self.stdout.write(f"{file_path!s} already exists in {processed_path!s}, removing original file.")
|
tqdm.write(f"{file_path!s} already exists in {processed_path!s}, removing original file.")
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self.stdout.write(f"{file_path!s} not found, skipping.")
|
tqdm.write(f"{file_path!s} not found when handling duplicate case, skipping.")
|
||||||
|
except FileNotFoundError:
|
||||||
|
tqdm.write(f"{file_path!s} not found, skipping.")
|
||||||
except (PermissionError, OSError, shutil.Error) as e:
|
except (PermissionError, OSError, shutil.Error) as e:
|
||||||
self.stdout.write(self.style.ERROR(f"Error moving {file_path!s} to {processed_path!s}: {e}"))
|
self.stdout.write(self.style.ERROR(f"Error moving {file_path!s} to {processed_path!s}: {e}"))
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
def import_drop_campaign(self, data: dict[str, Any], file_path: Path) -> None:
|
def import_drop_campaign(self, data: dict[str, Any], file_path: Path) -> None:
|
||||||
"""Find and import drop campaign data from various JSON structures.
|
"""Find and import drop campaign data from various JSON structures."""
|
||||||
|
|
||||||
Args:
|
|
||||||
data: The JSON data.
|
|
||||||
file_path: The path to the file being processed.
|
|
||||||
"""
|
|
||||||
# Add this check: If this is a known "empty" response, ignore it silently.
|
# Add this check: If this is a known "empty" response, ignore it silently.
|
||||||
if (
|
if (
|
||||||
"data" in data
|
"data" in data
|
||||||
|
|
@ -403,7 +445,7 @@ class Command(BaseCommand):
|
||||||
d: The dictionary to check for drop campaign data.
|
d: The dictionary to check for drop campaign data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if any drop campaign data was imported, False otherwise.
|
True if import was attempted, False otherwise.
|
||||||
"""
|
"""
|
||||||
if not isinstance(d, dict):
|
if not isinstance(d, dict):
|
||||||
return False
|
return False
|
||||||
|
|
@ -454,7 +496,7 @@ class Command(BaseCommand):
|
||||||
self.import_to_db(data, file_path=file_path)
|
self.import_to_db(data, file_path=file_path)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.stdout.write(self.style.WARNING(f"No valid drop campaign data found in {file_path.name}"))
|
tqdm.write(self.style.WARNING(f"No valid drop campaign data found in {file_path.name}"))
|
||||||
|
|
||||||
def import_to_db(self, campaign_data: dict[str, Any], file_path: Path) -> None:
|
def import_to_db(self, campaign_data: dict[str, Any], file_path: Path) -> None:
|
||||||
"""Import drop campaign data into the database with retry logic for SQLite locks.
|
"""Import drop campaign data into the database with retry logic for SQLite locks.
|
||||||
|
|
@ -467,7 +509,7 @@ class Command(BaseCommand):
|
||||||
game: Game = self.game_update_or_create(campaign_data=campaign_data)
|
game: Game = self.game_update_or_create(campaign_data=campaign_data)
|
||||||
organization: Organization | None = self.owner_update_or_create(campaign_data=campaign_data)
|
organization: Organization | None = self.owner_update_or_create(campaign_data=campaign_data)
|
||||||
|
|
||||||
if organization:
|
if organization and game.owner != organization:
|
||||||
game.owner = organization
|
game.owner = organization
|
||||||
game.save(update_fields=["owner"])
|
game.save(update_fields=["owner"])
|
||||||
|
|
||||||
|
|
@ -476,14 +518,12 @@ class Command(BaseCommand):
|
||||||
for drop_data in campaign_data.get("timeBasedDrops", []):
|
for drop_data in campaign_data.get("timeBasedDrops", []):
|
||||||
self._process_time_based_drop(drop_data, drop_campaign, file_path)
|
self._process_time_based_drop(drop_data, drop_campaign, file_path)
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Successfully imported drop campaign {drop_campaign.name} (ID: {drop_campaign.id})"))
|
|
||||||
|
|
||||||
def _process_time_based_drop(self, drop_data: dict[str, Any], drop_campaign: DropCampaign, file_path: Path) -> None:
|
def _process_time_based_drop(self, drop_data: dict[str, Any], drop_campaign: DropCampaign, file_path: Path) -> None:
|
||||||
time_based_drop: TimeBasedDrop = self.create_time_based_drop(drop_campaign=drop_campaign, drop_data=drop_data)
|
time_based_drop: TimeBasedDrop = self.create_time_based_drop(drop_campaign=drop_campaign, drop_data=drop_data)
|
||||||
|
|
||||||
benefit_edges: list[dict[str, Any]] = drop_data.get("benefitEdges", [])
|
benefit_edges: list[dict[str, Any]] = drop_data.get("benefitEdges", [])
|
||||||
if not benefit_edges:
|
if not benefit_edges:
|
||||||
self.stdout.write(self.style.WARNING(f"No benefit edges found for drop {time_based_drop.name} (ID: {time_based_drop.id})"))
|
tqdm.write(self.style.WARNING(f"No benefit edges found for drop {time_based_drop.name} (ID: {time_based_drop.id})"))
|
||||||
self.move_file(file_path, Path("no_benefit_edges") / file_path.name)
|
self.move_file(file_path, Path("no_benefit_edges") / file_path.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -499,30 +539,31 @@ class Command(BaseCommand):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run .strip() on all string fields to remove leading/trailing whitespace
|
# Run .strip() on all string fields to remove leading/trailing whitespace
|
||||||
for key, value in benefit_defaults.items():
|
for key, value in list(benefit_defaults.items()):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
benefit_defaults[key] = value.strip()
|
benefit_defaults[key] = value.strip()
|
||||||
|
|
||||||
# Filter out None values to avoid overwriting with them
|
# Filter out None values to avoid overwriting with them
|
||||||
benefit_defaults = {k: v for k, v in benefit_defaults.items() if v is not None}
|
benefit_defaults = {k: v for k, v in benefit_defaults.items() if v is not None}
|
||||||
|
|
||||||
benefit, _ = DropBenefit.objects.update_or_create(
|
# Use cached create/update for benefits
|
||||||
id=benefit_data["id"],
|
benefit = self._get_or_create_benefit(benefit_data["id"], benefit_defaults)
|
||||||
defaults=benefit_defaults,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cache benefit image if available and not already cached
|
try:
|
||||||
if (not benefit.image_file) and benefit.image_asset_url:
|
with transaction.atomic():
|
||||||
rel_path: str | None = cache_remote_image(benefit.image_asset_url, "benefits/images")
|
drop_benefit_edge, created = DropBenefitEdge.objects.update_or_create(
|
||||||
if rel_path:
|
|
||||||
benefit.image_file.name = rel_path
|
|
||||||
benefit.save(update_fields=["image_file"])
|
|
||||||
|
|
||||||
DropBenefitEdge.objects.update_or_create(
|
|
||||||
drop=time_based_drop,
|
drop=time_based_drop,
|
||||||
benefit=benefit,
|
benefit=benefit,
|
||||||
defaults={"entitlement_limit": benefit_edge.get("entitlementLimit", 1)},
|
defaults={"entitlement_limit": benefit_edge.get("entitlementLimit", 1)},
|
||||||
)
|
)
|
||||||
|
if created:
|
||||||
|
tqdm.write(f"Added {drop_benefit_edge}")
|
||||||
|
except MultipleObjectsReturned as e:
|
||||||
|
msg = f"Error: Multiple DropBenefitEdge objects found for drop {time_based_drop.id} and benefit {benefit.id}. Cannot update or create."
|
||||||
|
raise CommandError(msg) from e
|
||||||
|
except (IntegrityError, DatabaseError, TypeError, ValueError) as e:
|
||||||
|
msg = f"Database or validation error creating DropBenefitEdge for drop {time_based_drop.id} and benefit {benefit.id}: {e}"
|
||||||
|
raise CommandError(msg) from e
|
||||||
|
|
||||||
def create_time_based_drop(self, drop_campaign: DropCampaign, drop_data: dict[str, Any]) -> TimeBasedDrop:
|
def create_time_based_drop(self, drop_campaign: DropCampaign, drop_data: dict[str, Any]) -> TimeBasedDrop:
|
||||||
"""Creates or updates a TimeBasedDrop instance based on the provided drop data.
|
"""Creates or updates a TimeBasedDrop instance based on the provided drop data.
|
||||||
|
|
@ -537,9 +578,11 @@ class Command(BaseCommand):
|
||||||
- "startAt" (str, optional): ISO 8601 datetime string for when the drop starts.
|
- "startAt" (str, optional): ISO 8601 datetime string for when the drop starts.
|
||||||
- "endAt" (str, optional): ISO 8601 datetime string for when the drop ends.
|
- "endAt" (str, optional): ISO 8601 datetime string for when the drop ends.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CommandError: If there is a database error or multiple objects are returned.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
TimeBasedDrop: The created or updated TimeBasedDrop instance.
|
TimeBasedDrop: The created or updated TimeBasedDrop instance.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
time_based_drop_defaults: dict[str, Any] = {
|
time_based_drop_defaults: dict[str, Any] = {
|
||||||
"campaign": drop_campaign,
|
"campaign": drop_campaign,
|
||||||
|
|
@ -551,35 +594,182 @@ class Command(BaseCommand):
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run .strip() on all string fields to remove leading/trailing whitespace
|
# Run .strip() on all string fields to remove leading/trailing whitespace
|
||||||
for key, value in time_based_drop_defaults.items():
|
for key, value in list(time_based_drop_defaults.items()):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
time_based_drop_defaults[key] = value.strip()
|
time_based_drop_defaults[key] = value.strip()
|
||||||
|
|
||||||
# Filter out None values to avoid overwriting with them
|
# Filter out None values to avoid overwriting with them
|
||||||
time_based_drop_defaults = {k: v for k, v in time_based_drop_defaults.items() if v is not None}
|
time_based_drop_defaults = {k: v for k, v in time_based_drop_defaults.items() if v is not None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
time_based_drop, created = TimeBasedDrop.objects.update_or_create(id=drop_data["id"], defaults=time_based_drop_defaults)
|
time_based_drop, created = TimeBasedDrop.objects.update_or_create(id=drop_data["id"], defaults=time_based_drop_defaults)
|
||||||
if created:
|
if created:
|
||||||
self.stdout.write(self.style.SUCCESS(f"Successfully imported time-based drop {time_based_drop.name} (ID: {time_based_drop.id})"))
|
tqdm.write(f"Added {time_based_drop}")
|
||||||
|
except MultipleObjectsReturned as e:
|
||||||
|
msg = f"Error: Multiple TimeBasedDrop objects found for drop {drop_data['id']}. Cannot update or create."
|
||||||
|
raise CommandError(msg) from e
|
||||||
|
except (IntegrityError, DatabaseError, TypeError, ValueError) as e:
|
||||||
|
msg = f"Database or validation error creating TimeBasedDrop for drop {drop_data['id']}: {e}"
|
||||||
|
raise CommandError(msg) from e
|
||||||
|
|
||||||
return time_based_drop
|
return time_based_drop
|
||||||
|
|
||||||
def drop_campaign_update_or_get(
|
def _get_or_create_cached(
|
||||||
self,
|
self,
|
||||||
campaign_data: dict[str, Any],
|
model_name: str,
|
||||||
game: Game,
|
model_class: type[Game | Organization | DropCampaign | Channel | DropBenefit],
|
||||||
) -> DropCampaign:
|
obj_id: str | int,
|
||||||
"""Update or create a drop campaign.
|
defaults: dict[str, Any] | None = None,
|
||||||
|
) -> Game | Organization | DropCampaign | Channel | DropBenefit | str | int | None:
|
||||||
|
"""Generic get-or-create that uses the in-memory cache and writes only if needed.
|
||||||
|
|
||||||
|
This implementation is thread-safe and transaction-aware.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
campaign_data: The drop campaign data to import.
|
model_name: The name of the model (used for cache and lock).
|
||||||
game: The game this drop campaign is for.
|
model_class: The Django model class.
|
||||||
organization: The company that owns the game. If None, the campaign will not have an owner.
|
obj_id: The ID of the object to get or create.
|
||||||
|
defaults: A dictionary of fields to set on creation or update.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Returns the DropCampaign object.
|
The retrieved or created object.
|
||||||
|
"""
|
||||||
|
sid = str(obj_id)
|
||||||
|
defaults = defaults or {}
|
||||||
|
|
||||||
|
lock = self._cache_locks.get(model_name)
|
||||||
|
if lock is None:
|
||||||
|
# Fallback for models without a dedicated cache/lock
|
||||||
|
obj, created = model_class.objects.update_or_create(id=obj_id, defaults=defaults)
|
||||||
|
if created:
|
||||||
|
tqdm.write(f"Added {obj}")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
with lock:
|
||||||
|
cache = getattr(self, f"_{model_name}_cache", None)
|
||||||
|
if cache is None:
|
||||||
|
cache = {}
|
||||||
|
setattr(self, f"_{model_name}_cache", cache)
|
||||||
|
|
||||||
|
# First, check the cache.
|
||||||
|
cached_obj = cache.get(sid)
|
||||||
|
if cached_obj:
|
||||||
|
return cached_obj
|
||||||
|
|
||||||
|
# Not in cache, so we need to go to the database.
|
||||||
|
# Use get_or_create which is safer in a race. It might still fail if two threads
|
||||||
|
# try to create at the exact same time, so we wrap it.
|
||||||
|
try:
|
||||||
|
obj, created = model_class.objects.get_or_create(id=obj_id, defaults=defaults)
|
||||||
|
except IntegrityError:
|
||||||
|
# Another thread created it between our `get` and `create` attempt.
|
||||||
|
# The object is guaranteed to exist now, so we can just fetch it.
|
||||||
|
obj = model_class.objects.get(id=obj_id)
|
||||||
|
created = False
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
# The object already existed, check if our data is newer and update if needed.
|
||||||
|
changed = False
|
||||||
|
update_fields = []
|
||||||
|
for key, val in defaults.items():
|
||||||
|
if hasattr(obj, key) and getattr(obj, key) != val:
|
||||||
|
setattr(obj, key, val)
|
||||||
|
changed = True
|
||||||
|
update_fields.append(key)
|
||||||
|
if changed:
|
||||||
|
obj.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
# IMPORTANT: Defer the cache update until the transaction is successful.
|
||||||
|
# This is the key to preventing the race condition.
|
||||||
|
transaction.on_commit(lambda: cache.update({sid: obj}))
|
||||||
|
|
||||||
|
if created:
|
||||||
|
tqdm.write(f"Added {obj}")
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _get_or_create_benefit(self, benefit_id: str | int, defaults: dict[str, Any]) -> DropBenefit:
|
||||||
|
return self._get_or_create_cached("benefit", DropBenefit, benefit_id, defaults) # pyright: ignore[reportReturnType]
|
||||||
|
|
||||||
|
def game_update_or_create(self, campaign_data: dict[str, Any]) -> Game:
|
||||||
|
"""Update or create a game with caching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
campaign_data: The campaign data containing game information.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the retrieved object is not a Game instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The retrieved or created Game object.
|
||||||
|
"""
|
||||||
|
game_data: dict[str, Any] = campaign_data["game"]
|
||||||
|
|
||||||
|
game_defaults: dict[str, Any] = {
|
||||||
|
"name": game_data.get("name"),
|
||||||
|
"display_name": game_data.get("displayName"),
|
||||||
|
"box_art": game_data.get("boxArtURL"),
|
||||||
|
"slug": game_data.get("slug"),
|
||||||
|
}
|
||||||
|
# Filter out None values to avoid overwriting with them
|
||||||
|
game_defaults = {k: v for k, v in game_defaults.items() if v is not None}
|
||||||
|
|
||||||
|
game: Game | Organization | DropCampaign | Channel | DropBenefit | str | int | None = self._get_or_create_cached(
|
||||||
|
model_name="game",
|
||||||
|
model_class=Game,
|
||||||
|
obj_id=game_data["id"],
|
||||||
|
defaults=game_defaults,
|
||||||
|
)
|
||||||
|
if not isinstance(game, Game):
|
||||||
|
msg = "Expected a Game instance from _get_or_create_cached"
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
return game
|
||||||
|
|
||||||
|
def owner_update_or_create(self, campaign_data: dict[str, Any]) -> Organization | None:
|
||||||
|
"""Update or create an organization with caching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
campaign_data: The campaign data containing owner information.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the retrieved object is not an Organization instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The retrieved or created Organization object, or None if no owner data is present.
|
||||||
|
"""
|
||||||
|
org_data: dict[str, Any] = campaign_data.get("owner", {})
|
||||||
|
if org_data:
|
||||||
|
org_defaults: dict[str, Any] = {"name": org_data.get("name")}
|
||||||
|
org_defaults = {k: v.strip() if isinstance(v, str) else v for k, v in org_defaults.items() if v is not None}
|
||||||
|
|
||||||
|
owner = self._get_or_create_cached(
|
||||||
|
model_name="org",
|
||||||
|
model_class=Organization,
|
||||||
|
obj_id=org_data["id"],
|
||||||
|
defaults=org_defaults,
|
||||||
|
)
|
||||||
|
if not isinstance(owner, Organization):
|
||||||
|
msg = "Expected an Organization instance from _get_or_create_cached"
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
return owner
|
||||||
|
return None
|
||||||
|
|
||||||
|
def drop_campaign_update_or_get(self, campaign_data: dict[str, Any], game: Game) -> DropCampaign:
|
||||||
|
"""Update or create a drop campaign with caching and channel handling.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
campaign_data: The campaign data containing drop campaign information.
|
||||||
|
game: The associated Game object.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If the retrieved object is not a DropCampaign instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The retrieved or created DropCampaign object.
|
||||||
"""
|
"""
|
||||||
# Extract allow data from campaign_data
|
|
||||||
allow_data = campaign_data.get("allow", {})
|
allow_data = campaign_data.get("allow", {})
|
||||||
allow_is_enabled = allow_data.get("isEnabled")
|
allow_is_enabled = allow_data.get("isEnabled")
|
||||||
|
|
||||||
|
|
@ -595,18 +785,24 @@ class Command(BaseCommand):
|
||||||
"is_account_connected": campaign_data.get("self", {}).get("isAccountConnected"),
|
"is_account_connected": campaign_data.get("self", {}).get("isAccountConnected"),
|
||||||
"allow_is_enabled": allow_is_enabled,
|
"allow_is_enabled": allow_is_enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Run .strip() on all string fields to remove leading/trailing whitespace
|
# Run .strip() on all string fields to remove leading/trailing whitespace
|
||||||
for key, value in drop_campaign_defaults.items():
|
for key, value in list(drop_campaign_defaults.items()):
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
drop_campaign_defaults[key] = value.strip()
|
drop_campaign_defaults[key] = value.strip()
|
||||||
|
|
||||||
# Filter out None values to avoid overwriting with them
|
# Filter out None values to avoid overwriting with them
|
||||||
drop_campaign_defaults = {k: v for k, v in drop_campaign_defaults.items() if v is not None}
|
drop_campaign_defaults = {k: v for k, v in drop_campaign_defaults.items() if v is not None}
|
||||||
|
|
||||||
drop_campaign, created = DropCampaign.objects.update_or_create(
|
drop_campaign = self._get_or_create_cached(
|
||||||
id=campaign_data["id"],
|
model_name="campaign",
|
||||||
|
model_class=DropCampaign,
|
||||||
|
obj_id=campaign_data["id"],
|
||||||
defaults=drop_campaign_defaults,
|
defaults=drop_campaign_defaults,
|
||||||
)
|
)
|
||||||
|
if not isinstance(drop_campaign, DropCampaign):
|
||||||
|
msg = "Expected a DropCampaign instance from _get_or_create_cached"
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
# Handle allow_channels (many-to-many relationship)
|
# Handle allow_channels (many-to-many relationship)
|
||||||
allow_channels: list[dict[str, str]] = allow_data.get("channels", [])
|
allow_channels: list[dict[str, str]] = allow_data.get("channels", [])
|
||||||
|
|
@ -625,87 +821,23 @@ class Command(BaseCommand):
|
||||||
# Filter out None values
|
# Filter out None values
|
||||||
channel_defaults = {k: v for k, v in channel_defaults.items() if v is not None}
|
channel_defaults = {k: v for k, v in channel_defaults.items() if v is not None}
|
||||||
|
|
||||||
channel, _ = Channel.objects.update_or_create(
|
# Use cached helper for channels
|
||||||
id=channel_data["id"],
|
channel = self._get_or_create_cached(
|
||||||
|
model_name="channel",
|
||||||
|
model_class=Channel,
|
||||||
|
obj_id=channel_data["id"],
|
||||||
defaults=channel_defaults,
|
defaults=channel_defaults,
|
||||||
)
|
)
|
||||||
|
if not isinstance(channel, Channel):
|
||||||
|
msg = "Expected a Channel instance from _get_or_create_cached"
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
channel_objects.append(channel)
|
channel_objects.append(channel)
|
||||||
|
|
||||||
# Set the many-to-many relationship
|
# Set the many-to-many relationship (save only if different)
|
||||||
|
current_ids = set(drop_campaign.allow_channels.values_list("id", flat=True))
|
||||||
|
new_ids = {ch.id for ch in channel_objects}
|
||||||
|
if current_ids != new_ids:
|
||||||
drop_campaign.allow_channels.set(channel_objects)
|
drop_campaign.allow_channels.set(channel_objects)
|
||||||
|
|
||||||
if created:
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Created new drop campaign: {drop_campaign.name} (ID: {drop_campaign.id})"))
|
|
||||||
|
|
||||||
# Cache campaign image if available and not already cached
|
|
||||||
if (not drop_campaign.image_file) and drop_campaign.image_url:
|
|
||||||
rel_path: str | None = cache_remote_image(drop_campaign.image_url, "campaigns/images")
|
|
||||||
if rel_path:
|
|
||||||
drop_campaign.image_file.name = rel_path
|
|
||||||
drop_campaign.save(update_fields=["image_file"]) # type: ignore[list-item]
|
|
||||||
return drop_campaign
|
return drop_campaign
|
||||||
|
|
||||||
def owner_update_or_create(self, campaign_data: dict[str, Any]) -> Organization | None:
|
|
||||||
"""Update or create an organization.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
campaign_data: The drop campaign data to import.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Returns the Organization object.
|
|
||||||
"""
|
|
||||||
org_data: dict[str, Any] = campaign_data.get("owner", {})
|
|
||||||
if org_data:
|
|
||||||
org_defaults: dict[str, Any] = {"name": org_data.get("name")}
|
|
||||||
|
|
||||||
# Run .strip() on all string fields to remove leading/trailing whitespace
|
|
||||||
for key, value in org_defaults.items():
|
|
||||||
if isinstance(value, str):
|
|
||||||
org_defaults[key] = value.strip()
|
|
||||||
|
|
||||||
# Filter out None values to avoid overwriting with them
|
|
||||||
org_defaults = {k: v for k, v in org_defaults.items() if v is not None}
|
|
||||||
|
|
||||||
organization, created = Organization.objects.update_or_create(
|
|
||||||
id=org_data["id"],
|
|
||||||
defaults=org_defaults,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Created new organization: {organization.name} (ID: {organization.id})"))
|
|
||||||
return organization
|
|
||||||
return None
|
|
||||||
|
|
||||||
def game_update_or_create(self, campaign_data: dict[str, Any]) -> Game:
|
|
||||||
"""Update or create a game.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
campaign_data: The drop campaign data to import.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Returns the Game object.
|
|
||||||
"""
|
|
||||||
game_data: dict[str, Any] = campaign_data["game"]
|
|
||||||
|
|
||||||
game_defaults: dict[str, Any] = {
|
|
||||||
"name": game_data.get("name"),
|
|
||||||
"display_name": game_data.get("displayName"),
|
|
||||||
"box_art": game_data.get("boxArtURL"),
|
|
||||||
"slug": game_data.get("slug"),
|
|
||||||
}
|
|
||||||
# Filter out None values to avoid overwriting with them
|
|
||||||
game_defaults = {k: v for k, v in game_defaults.items() if v is not None}
|
|
||||||
|
|
||||||
game, created = Game.objects.update_or_create(
|
|
||||||
id=game_data["id"],
|
|
||||||
defaults=game_defaults,
|
|
||||||
)
|
|
||||||
if created:
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Created new game: {game.display_name} (ID: {game.id})"))
|
|
||||||
|
|
||||||
# Cache game box art if available and not already cached
|
|
||||||
if (not game.box_art_file) and game.box_art:
|
|
||||||
rel_path: str | None = cache_remote_image(game.box_art, "games/box_art")
|
|
||||||
if rel_path:
|
|
||||||
game.box_art_file.name = rel_path
|
|
||||||
game.save(update_fields=["box_art_file"])
|
|
||||||
return game
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"""Management command to update PostgreSQL search vectors."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.contrib.postgres.search import SearchVector
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from twitch.models import DropBenefit, DropCampaign, Game, Organization, TimeBasedDrop
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
"""Update search vectors for existing records."""
|
|
||||||
|
|
||||||
help = "Update PostgreSQL search vectors for existing records"
|
|
||||||
|
|
||||||
def handle(self, *_args, **_options) -> None:
|
|
||||||
"""Update search vectors for all models."""
|
|
||||||
self.stdout.write("Updating search vectors...")
|
|
||||||
|
|
||||||
# Update Organizations
|
|
||||||
org_count = Organization.objects.update(search_vector=SearchVector("name"))
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {org_count} organizations"))
|
|
||||||
|
|
||||||
# Update Games
|
|
||||||
game_count = Game.objects.update(search_vector=SearchVector("name", "display_name"))
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {game_count} games"))
|
|
||||||
|
|
||||||
# Update DropCampaigns
|
|
||||||
campaign_count = DropCampaign.objects.update(search_vector=SearchVector("name", "description"))
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {campaign_count} campaigns"))
|
|
||||||
|
|
||||||
# Update TimeBasedDrops
|
|
||||||
drop_count = TimeBasedDrop.objects.update(search_vector=SearchVector("name"))
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {drop_count} time-based drops"))
|
|
||||||
|
|
||||||
# Update DropBenefits
|
|
||||||
benefit_count = DropBenefit.objects.update(search_vector=SearchVector("name"))
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Successfully updated search vectors for {benefit_count} drop benefits"))
|
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("All search vectors updated."))
|
|
||||||
|
|
@ -1,179 +1,344 @@
|
||||||
# Generated by Django 5.2.4 on 2025-08-06 04:12
|
# Generated by Django 5.2.7 on 2025-10-13 00:00
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
"""Initial migration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
migrations (migrations.Migration): The base class for all migrations.
|
||||||
|
"""
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='DropBenefit',
|
name="Game",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.TextField(primary_key=True, serialize=False)),
|
("id", models.TextField(primary_key=True, serialize=False, verbose_name="Game ID")),
|
||||||
('name', models.TextField(db_index=True)),
|
(
|
||||||
('image_asset_url', models.URLField(blank=True, default='', max_length=500)),
|
"slug",
|
||||||
('created_at', models.DateTimeField(db_index=True)),
|
models.TextField(
|
||||||
('entitlement_limit', models.PositiveIntegerField(default=1)),
|
blank=True, db_index=True, default="", help_text="Short unique identifier for the game.", max_length=200, verbose_name="Slug"
|
||||||
('is_ios_available', models.BooleanField(default=False)),
|
|
||||||
('distribution_type', models.TextField(db_index=True)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
|
||||||
name='DropBenefitEdge',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('entitlement_limit', models.PositiveIntegerField(default=1)),
|
|
||||||
('benefit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='twitch.dropbenefit')),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
("name", models.TextField(blank=True, db_index=True, default="", verbose_name="Name")),
|
||||||
name='Game',
|
("display_name", models.TextField(blank=True, db_index=True, default="", verbose_name="Display name")),
|
||||||
fields=[
|
("box_art", models.URLField(blank=True, default="", max_length=500, verbose_name="Box art URL")),
|
||||||
('id', models.TextField(primary_key=True, serialize=False)),
|
(
|
||||||
('slug', models.TextField(blank=True, db_index=True, default='')),
|
"box_art_file",
|
||||||
('display_name', models.TextField(db_index=True)),
|
models.FileField(blank=True, help_text="Locally cached box art image served from this site.", null=True, upload_to="games/box_art/"),
|
||||||
('box_art', models.URLField(blank=True, default='', max_length=500)),
|
),
|
||||||
|
("added_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Timestamp when this game record was created.")),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True, help_text="Timestamp when this game record was last updated.")),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'indexes': [models.Index(fields=['slug'], name='twitch_game_slug_a02d3c_idx'), models.Index(fields=['display_name'], name='twitch_game_display_a35ba3_idx')],
|
"ordering": ["display_name"],
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='game',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='drop_benefits', to='twitch.game'),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Organization',
|
|
||||||
fields=[
|
|
||||||
('id', models.TextField(primary_key=True, serialize=False)),
|
|
||||||
('name', models.TextField(db_index=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'indexes': [models.Index(fields=['name'], name='twitch_orga_name_febe72_idx')],
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='DropCampaign',
|
name="Channel",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.TextField(primary_key=True, serialize=False)),
|
(
|
||||||
('name', models.TextField(db_index=True)),
|
"id",
|
||||||
('description', models.TextField(blank=True)),
|
models.TextField(help_text="The unique Twitch identifier for the channel.", primary_key=True, serialize=False, verbose_name="Channel ID"),
|
||||||
('details_url', models.URLField(blank=True, default='', max_length=500)),
|
|
||||||
('account_link_url', models.URLField(blank=True, default='', max_length=500)),
|
|
||||||
('image_url', models.URLField(blank=True, default='', max_length=500)),
|
|
||||||
('start_at', models.DateTimeField(db_index=True)),
|
|
||||||
('end_at', models.DateTimeField(db_index=True)),
|
|
||||||
('is_account_connected', models.BooleanField(default=False)),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='drop_campaigns', to='twitch.game')),
|
|
||||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='drop_campaigns', to='twitch.organization')),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
("name", models.TextField(db_index=True, help_text="The lowercase username of the channel.", verbose_name="Username")),
|
||||||
model_name='dropbenefit',
|
(
|
||||||
name='owner_organization',
|
"display_name",
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='drop_benefits', to='twitch.organization'),
|
models.TextField(db_index=True, help_text="The display name of the channel (with proper capitalization).", verbose_name="Display Name"),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
("added_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Timestamp when this channel record was created.")),
|
||||||
name='TimeBasedDrop',
|
("updated_at", models.DateTimeField(auto_now=True, help_text="Timestamp when this channel record was last updated.")),
|
||||||
fields=[
|
|
||||||
('id', models.TextField(primary_key=True, serialize=False)),
|
|
||||||
('name', models.TextField(db_index=True)),
|
|
||||||
('required_minutes_watched', models.PositiveIntegerField(db_index=True)),
|
|
||||||
('required_subs', models.PositiveIntegerField(default=0)),
|
|
||||||
('start_at', models.DateTimeField(db_index=True)),
|
|
||||||
('end_at', models.DateTimeField(db_index=True)),
|
|
||||||
('benefits', models.ManyToManyField(related_name='drops', through='twitch.DropBenefitEdge', to='twitch.dropbenefit')),
|
|
||||||
('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_based_drops', to='twitch.dropcampaign')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
name='drop',
|
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='twitch.timebaseddrop'),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='NotificationSubscription',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('notify_found', models.BooleanField(default=False)),
|
|
||||||
('notify_live', models.BooleanField(default=False)),
|
|
||||||
('game', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='twitch.game')),
|
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
|
||||||
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='twitch.organization')),
|
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'unique_together': {('user', 'game'), ('user', 'organization')},
|
"ordering": ["display_name"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["name"], name="twitch_chan_name_15d566_idx"),
|
||||||
|
models.Index(fields=["display_name"], name="twitch_chan_display_2bf213_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DropBenefit",
|
||||||
|
fields=[
|
||||||
|
("id", models.TextField(help_text="Unique Twitch identifier for the benefit.", primary_key=True, serialize=False)),
|
||||||
|
("name", models.TextField(blank=True, db_index=True, default="N/A", help_text="Name of the drop benefit.")),
|
||||||
|
("image_asset_url", models.URLField(blank=True, default="", help_text="URL to the benefit's image asset.", max_length=500)),
|
||||||
|
(
|
||||||
|
"image_file",
|
||||||
|
models.FileField(blank=True, help_text="Locally cached benefit image served from this site.", null=True, upload_to="benefits/images/"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"created_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
db_index=True, help_text="Timestamp when the benefit was created. This is from Twitch API and not auto-generated.", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("entitlement_limit", models.PositiveIntegerField(default=1, help_text="Maximum number of times this benefit can be earned.")),
|
||||||
|
("is_ios_available", models.BooleanField(default=False, help_text="Whether the benefit is available on iOS.")),
|
||||||
|
(
|
||||||
|
"distribution_type",
|
||||||
|
models.TextField(blank=True, db_index=True, default="", help_text="Type of distribution for this benefit.", max_length=50),
|
||||||
|
),
|
||||||
|
("added_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Timestamp when this benefit record was created.")),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True, help_text="Timestamp when this benefit record was last updated.")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["name"], name="twitch_drop_name_7125ff_idx"),
|
||||||
|
models.Index(fields=["created_at"], name="twitch_drop_created_a3563e_idx"),
|
||||||
|
models.Index(fields=["distribution_type"], name="twitch_drop_distrib_08b224_idx"),
|
||||||
|
models.Index(condition=models.Q(("is_ios_available", True)), fields=["is_ios_available"], name="benefit_ios_available_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DropBenefitEdge",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
("entitlement_limit", models.PositiveIntegerField(default=1, help_text="Max times this benefit can be claimed for this drop.")),
|
||||||
|
("added_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Timestamp when this drop-benefit edge was created.")),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True, help_text="Timestamp when this drop-benefit edge was last updated.")),
|
||||||
|
(
|
||||||
|
"benefit",
|
||||||
|
models.ForeignKey(help_text="The benefit in this relationship.", on_delete=django.db.models.deletion.CASCADE, to="twitch.dropbenefit"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DropCampaign",
|
||||||
|
fields=[
|
||||||
|
("id", models.TextField(help_text="Unique Twitch identifier for the campaign.", primary_key=True, serialize=False)),
|
||||||
|
("name", models.TextField(db_index=True, help_text="Name of the drop campaign.")),
|
||||||
|
("description", models.TextField(blank=True, help_text="Detailed description of the campaign.")),
|
||||||
|
("details_url", models.URLField(blank=True, default="", help_text="URL with campaign details.", max_length=500)),
|
||||||
|
("account_link_url", models.URLField(blank=True, default="", help_text="URL to link a Twitch account for the campaign.", max_length=500)),
|
||||||
|
("image_url", models.URLField(blank=True, default="", help_text="URL to an image representing the campaign.", max_length=500)),
|
||||||
|
(
|
||||||
|
"image_file",
|
||||||
|
models.FileField(blank=True, help_text="Locally cached campaign image served from this site.", null=True, upload_to="campaigns/images/"),
|
||||||
|
),
|
||||||
|
("start_at", models.DateTimeField(blank=True, db_index=True, help_text="Datetime when the campaign starts.", null=True)),
|
||||||
|
("end_at", models.DateTimeField(blank=True, db_index=True, help_text="Datetime when the campaign ends.", null=True)),
|
||||||
|
("is_account_connected", models.BooleanField(default=False, help_text="Indicates if the user account is linked.")),
|
||||||
|
("allow_is_enabled", models.BooleanField(default=True, help_text="Whether the campaign allows participation.")),
|
||||||
|
("added_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Timestamp when this campaign record was created.")),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True, help_text="Timestamp when this campaign record was last updated.")),
|
||||||
|
(
|
||||||
|
"allow_channels",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Channels that are allowed to participate in this campaign.",
|
||||||
|
related_name="allowed_campaigns",
|
||||||
|
to="twitch.channel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"game",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="Game associated with this campaign.",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="drop_campaigns",
|
||||||
|
to="twitch.game",
|
||||||
|
verbose_name="Game",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-start_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Organization",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.TextField(
|
||||||
|
help_text="The unique Twitch identifier for the organization.", primary_key=True, serialize=False, verbose_name="Organization ID"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.TextField(db_index=True, help_text="Display name of the organization.", unique=True, verbose_name="Name")),
|
||||||
|
("added_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Timestamp when this organization record was created.")),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True, help_text="Timestamp when this organization record was last updated.")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
"indexes": [models.Index(fields=["name"], name="twitch_orga_name_febe72_idx")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="owner",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="The organization that owns this game.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="games",
|
||||||
|
to="twitch.organization",
|
||||||
|
verbose_name="Organization",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TimeBasedDrop",
|
||||||
|
fields=[
|
||||||
|
("id", models.TextField(help_text="Unique Twitch identifier for the time-based drop.", primary_key=True, serialize=False)),
|
||||||
|
("name", models.TextField(db_index=True, help_text="Name of the time-based drop.")),
|
||||||
|
(
|
||||||
|
"required_minutes_watched",
|
||||||
|
models.PositiveIntegerField(blank=True, db_index=True, help_text="Minutes required to watch before earning this drop.", null=True),
|
||||||
|
),
|
||||||
|
("required_subs", models.PositiveIntegerField(default=0, help_text="Number of subscriptions required to unlock this drop.")),
|
||||||
|
("start_at", models.DateTimeField(blank=True, db_index=True, help_text="Datetime when this drop becomes available.", null=True)),
|
||||||
|
("end_at", models.DateTimeField(blank=True, db_index=True, help_text="Datetime when this drop expires.", null=True)),
|
||||||
|
("added_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Timestamp when this time-based drop record was created.")),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True, help_text="Timestamp when this time-based drop record was last updated.")),
|
||||||
|
(
|
||||||
|
"benefits",
|
||||||
|
models.ManyToManyField(
|
||||||
|
help_text="Benefits unlocked by this drop.", related_name="drops", through="twitch.DropBenefitEdge", to="twitch.dropbenefit"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"campaign",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="The campaign this drop belongs to.",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="time_based_drops",
|
||||||
|
to="twitch.dropcampaign",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["start_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="dropbenefitedge",
|
||||||
|
name="drop",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text="The time-based drop in this relationship.", on_delete=django.db.models.deletion.CASCADE, to="twitch.timebaseddrop"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TwitchGameData",
|
||||||
|
fields=[
|
||||||
|
("id", models.TextField(primary_key=True, serialize=False, verbose_name="Twitch Game ID")),
|
||||||
|
("name", models.TextField(blank=True, db_index=True, default="", verbose_name="Name")),
|
||||||
|
(
|
||||||
|
"box_art_url",
|
||||||
|
models.URLField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="URL template with {width}x{height} placeholders for the box art image.",
|
||||||
|
max_length=500,
|
||||||
|
verbose_name="Box art URL",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("igdb_id", models.TextField(blank=True, default="", verbose_name="IGDB ID")),
|
||||||
|
("added_at", models.DateTimeField(auto_now_add=True, db_index=True, help_text="Record creation time.")),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True, help_text="Record last update time.")),
|
||||||
|
(
|
||||||
|
"game",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Optional link to the local Game record for this Twitch game.",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="twitch_game_data",
|
||||||
|
to="twitch.game",
|
||||||
|
verbose_name="Game",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropcampaign',
|
model_name="dropcampaign",
|
||||||
index=models.Index(fields=['name'], name='twitch_drop_name_3b70b3_idx'),
|
index=models.Index(fields=["name"], name="twitch_drop_name_3b70b3_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropcampaign',
|
model_name="dropcampaign",
|
||||||
index=models.Index(fields=['start_at', 'end_at'], name='twitch_drop_start_a_6e5fb6_idx'),
|
index=models.Index(fields=["start_at", "end_at"], name="twitch_drop_start_a_6e5fb6_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropcampaign',
|
model_name="dropcampaign",
|
||||||
index=models.Index(fields=['game'], name='twitch_drop_game_id_868e70_idx'),
|
index=models.Index(
|
||||||
|
condition=models.Q(("end_at__isnull", False), ("start_at__isnull", False)), fields=["start_at", "end_at"], name="campaign_active_partial_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="dropcampaign",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(("start_at__isnull", True), ("end_at__isnull", True), ("end_at__gt", models.F("start_at")), _connector="OR"),
|
||||||
|
name="campaign_valid_date_range",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropcampaign',
|
model_name="game",
|
||||||
index=models.Index(fields=['owner'], name='twitch_drop_owner_i_37241d_idx'),
|
index=models.Index(fields=["display_name"], name="twitch_game_display_a35ba3_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropbenefit',
|
model_name="game",
|
||||||
index=models.Index(fields=['name'], name='twitch_drop_name_7125ff_idx'),
|
index=models.Index(fields=["name"], name="twitch_game_name_c92c15_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropbenefit',
|
model_name="game",
|
||||||
index=models.Index(fields=['created_at'], name='twitch_drop_created_a3563e_idx'),
|
index=models.Index(fields=["slug"], name="twitch_game_slug_a02d3c_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropbenefit',
|
model_name="game",
|
||||||
index=models.Index(fields=['distribution_type'], name='twitch_drop_distrib_08b224_idx'),
|
index=models.Index(condition=models.Q(("owner__isnull", False)), fields=["owner"], name="game_owner_partial_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropbenefit',
|
model_name="timebaseddrop",
|
||||||
index=models.Index(fields=['game'], name='twitch_drop_game_id_a9209e_idx'),
|
index=models.Index(fields=["name"], name="twitch_time_name_47c0f4_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='dropbenefit',
|
model_name="timebaseddrop",
|
||||||
index=models.Index(fields=['owner_organization'], name='twitch_drop_owner_o_45b4cc_idx'),
|
index=models.Index(fields=["start_at", "end_at"], name="twitch_time_start_a_c481f1_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='timebaseddrop',
|
model_name="timebaseddrop",
|
||||||
index=models.Index(fields=['name'], name='twitch_time_name_47c0f4_idx'),
|
index=models.Index(fields=["required_minutes_watched"], name="twitch_time_require_82c30c_idx"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='timebaseddrop',
|
model_name="timebaseddrop",
|
||||||
index=models.Index(fields=['start_at', 'end_at'], name='twitch_time_start_a_c481f1_idx'),
|
index=models.Index(fields=["campaign", "start_at", "required_minutes_watched"], name="twitch_time_campaig_4cc3b7_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="timebaseddrop",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(("start_at__isnull", True), ("end_at__isnull", True), ("end_at__gt", models.F("start_at")), _connector="OR"),
|
||||||
|
name="drop_valid_date_range",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="timebaseddrop",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
condition=models.Q(("required_minutes_watched__isnull", True), ("required_minutes_watched__gte", 0), _connector="OR"),
|
||||||
|
name="drop_positive_minutes",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='timebaseddrop',
|
model_name="dropbenefitedge",
|
||||||
index=models.Index(fields=['campaign'], name='twitch_time_campaig_bbe349_idx'),
|
index=models.Index(fields=["drop", "benefit"], name="twitch_drop_drop_id_5a574c_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="dropbenefitedge",
|
||||||
|
constraint=models.UniqueConstraint(fields=("drop", "benefit"), name="unique_drop_benefit"),
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='timebaseddrop',
|
model_name="twitchgamedata",
|
||||||
index=models.Index(fields=['required_minutes_watched'], name='twitch_time_require_82c30c_idx'),
|
index=models.Index(fields=["name"], name="twitch_twit_name_5dda5f_idx"),
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
index=models.Index(fields=['drop', 'benefit'], name='twitch_drop_drop_id_5a574c_idx'),
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name='dropbenefitedge',
|
|
||||||
unique_together={('drop', 'benefit')},
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-08-07 02:35
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='game',
|
|
||||||
name='name',
|
|
||||||
field=models.TextField(blank=True, db_index=True, default=''),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='display_name',
|
|
||||||
field=models.TextField(blank=True, db_index=True, default=''),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='game',
|
|
||||||
index=models.Index(fields=['name'], name='twitch_game_name_c92c15_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='game',
|
|
||||||
index=models.Index(fields=['box_art'], name='twitch_game_box_art_498a89_idx'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-08-10 20:11
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0002_game_name_alter_game_display_name_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='created_at',
|
|
||||||
field=models.DateTimeField(db_index=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='distribution_type',
|
|
||||||
field=models.TextField(blank=True, db_index=True, default=''),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='name',
|
|
||||||
field=models.TextField(blank=True, db_index=True, default='N/A'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-08-10 20:49
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0003_alter_dropbenefit_created_at_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='end_at',
|
|
||||||
field=models.DateTimeField(db_index=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='start_at',
|
|
||||||
field=models.DateTimeField(db_index=True, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-08-10 20:50
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0004_alter_dropcampaign_end_at_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='end_at',
|
|
||||||
field=models.DateTimeField(db_index=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='required_minutes_watched',
|
|
||||||
field=models.PositiveIntegerField(db_index=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='start_at',
|
|
||||||
field=models.DateTimeField(db_index=True, null=True),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-09-01 17:01
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0005_alter_timebaseddrop_end_at_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='dropbenefit',
|
|
||||||
options={'ordering': ['-created_at']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='dropcampaign',
|
|
||||||
options={'ordering': ['-start_at']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='game',
|
|
||||||
options={'ordering': ['display_name']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='organization',
|
|
||||||
options={'ordering': ['name']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='timebaseddrop',
|
|
||||||
options={'ordering': ['start_at']},
|
|
||||||
),
|
|
||||||
migrations.RemoveIndex(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='twitch_drop_game_id_a9209e_idx',
|
|
||||||
),
|
|
||||||
migrations.RemoveIndex(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='twitch_drop_owner_o_45b4cc_idx',
|
|
||||||
),
|
|
||||||
migrations.RemoveIndex(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='twitch_drop_game_id_868e70_idx',
|
|
||||||
),
|
|
||||||
migrations.RemoveIndex(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='twitch_drop_owner_i_37241d_idx',
|
|
||||||
),
|
|
||||||
migrations.RemoveIndex(
|
|
||||||
model_name='game',
|
|
||||||
name='twitch_game_box_art_498a89_idx',
|
|
||||||
),
|
|
||||||
migrations.RemoveIndex(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='twitch_time_campaig_bbe349_idx',
|
|
||||||
),
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name='dropbenefitedge',
|
|
||||||
unique_together=set(),
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='game',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='owner_organization',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='owner',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='game',
|
|
||||||
name='owner',
|
|
||||||
field=models.ForeignKey(blank=True, help_text='The organization that owns this game.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='games', to='twitch.organization', verbose_name='Organization'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='created_at',
|
|
||||||
field=models.DateTimeField(db_index=True, help_text='Timestamp when the benefit was created. This is from Twitch API and not auto-generated.', null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='distribution_type',
|
|
||||||
field=models.CharField(blank=True, db_index=True, default='', help_text='Type of distribution for this benefit.', max_length=50),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='entitlement_limit',
|
|
||||||
field=models.PositiveIntegerField(default=1, help_text='Maximum number of times this benefit can be earned.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='id',
|
|
||||||
field=models.CharField(help_text='Unique Twitch identifier for the benefit.', max_length=64, primary_key=True, serialize=False),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='image_asset_url',
|
|
||||||
field=models.URLField(blank=True, default='', help_text="URL to the benefit's image asset.", max_length=500),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='is_ios_available',
|
|
||||||
field=models.BooleanField(default=False, help_text='Whether the benefit is available on iOS.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(blank=True, db_index=True, default='N/A', help_text='Name of the drop benefit.', max_length=255),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
name='benefit',
|
|
||||||
field=models.ForeignKey(help_text='The benefit in this relationship.', on_delete=django.db.models.deletion.CASCADE, to='twitch.dropbenefit'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
name='drop',
|
|
||||||
field=models.ForeignKey(help_text='The time-based drop in this relationship.', on_delete=django.db.models.deletion.CASCADE, to='twitch.timebaseddrop'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
name='entitlement_limit',
|
|
||||||
field=models.PositiveIntegerField(default=1, help_text='Max times this benefit can be claimed for this drop.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='account_link_url',
|
|
||||||
field=models.URLField(blank=True, default='', help_text='URL to link a Twitch account for the campaign.', max_length=500),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='created_at',
|
|
||||||
field=models.DateTimeField(auto_now_add=True, db_index=True, help_text='Timestamp when this campaign record was created.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='description',
|
|
||||||
field=models.TextField(blank=True, help_text='Detailed description of the campaign.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='details_url',
|
|
||||||
field=models.URLField(blank=True, default='', help_text='URL with campaign details.', max_length=500),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='end_at',
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, help_text='Datetime when the campaign ends.', null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='game',
|
|
||||||
field=models.ForeignKey(help_text='Game associated with this campaign.', on_delete=django.db.models.deletion.CASCADE, related_name='drop_campaigns', to='twitch.game', verbose_name='Game'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='id',
|
|
||||||
field=models.CharField(help_text='Unique Twitch identifier for the campaign.', max_length=255, primary_key=True, serialize=False),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='image_url',
|
|
||||||
field=models.URLField(blank=True, default='', help_text='URL to an image representing the campaign.', max_length=500),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='is_account_connected',
|
|
||||||
field=models.BooleanField(default=False, help_text='Indicates if the user account is linked.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(db_index=True, help_text='Name of the drop campaign.', max_length=255),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='start_at',
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, help_text='Datetime when the campaign starts.', null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this campaign record was last updated.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='box_art',
|
|
||||||
field=models.URLField(blank=True, default='', max_length=500, verbose_name='Box art URL'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='display_name',
|
|
||||||
field=models.CharField(blank=True, db_index=True, default='', max_length=255, verbose_name='Display name'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='id',
|
|
||||||
field=models.CharField(max_length=64, primary_key=True, serialize=False, verbose_name='Game ID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(blank=True, db_index=True, default='', max_length=255, verbose_name='Name'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='slug',
|
|
||||||
field=models.CharField(blank=True, db_index=True, default='', help_text='Short unique identifier for the game.', max_length=200, verbose_name='Slug'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='organization',
|
|
||||||
name='id',
|
|
||||||
field=models.CharField(help_text='The unique Twitch identifier for the organization.', max_length=255, primary_key=True, serialize=False, verbose_name='Organization ID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='organization',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(db_index=True, help_text='Display name of the organization.', max_length=255, unique=True, verbose_name='Name'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='benefits',
|
|
||||||
field=models.ManyToManyField(help_text='Benefits unlocked by this drop.', related_name='drops', through='twitch.DropBenefitEdge', to='twitch.dropbenefit'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='campaign',
|
|
||||||
field=models.ForeignKey(help_text='The campaign this drop belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='time_based_drops', to='twitch.dropcampaign'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='end_at',
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, help_text='Datetime when this drop expires.', null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='id',
|
|
||||||
field=models.CharField(help_text='Unique Twitch identifier for the time-based drop.', max_length=64, primary_key=True, serialize=False),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='name',
|
|
||||||
field=models.CharField(db_index=True, help_text='Name of the time-based drop.', max_length=255),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='required_minutes_watched',
|
|
||||||
field=models.PositiveIntegerField(blank=True, db_index=True, help_text='Minutes required to watch before earning this drop.', null=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='required_subs',
|
|
||||||
field=models.PositiveIntegerField(default=0, help_text='Number of subscriptions required to unlock this drop.'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='start_at',
|
|
||||||
field=models.DateTimeField(blank=True, db_index=True, help_text='Datetime when this drop becomes available.', null=True),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
constraint=models.UniqueConstraint(fields=('drop', 'benefit'), name='unique_drop_benefit'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='game',
|
|
||||||
constraint=models.UniqueConstraint(fields=('slug',), name='unique_game_slug'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-09-01 17:06
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name='game',
|
|
||||||
name='unique_game_slug',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-09-04 21:00
|
|
||||||
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0007_remove_game_unique_game_slug'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RenameField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
old_name='created_at',
|
|
||||||
new_name='added_at',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='added_at',
|
|
||||||
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this benefit record was created.'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this benefit record was last updated.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
name='added_at',
|
|
||||||
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this drop-benefit edge was created.'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this drop-benefit edge was last updated.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='game',
|
|
||||||
name='added_at',
|
|
||||||
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this game record was created.'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='game',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this game record was last updated.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='notificationsubscription',
|
|
||||||
name='added_at',
|
|
||||||
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='notificationsubscription',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='organization',
|
|
||||||
name='added_at',
|
|
||||||
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this organization record was created.'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='organization',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this organization record was last updated.'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='added_at',
|
|
||||||
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now, help_text='Timestamp when this time-based drop record was created.'),
|
|
||||||
preserve_default=False,
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this time-based drop record was last updated.'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-09-04 22:22
|
|
||||||
|
|
||||||
import django.contrib.postgres.indexes
|
|
||||||
import django.contrib.postgres.search
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0008_rename_created_at_dropcampaign_added_at_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='search_vector',
|
|
||||||
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
index=models.Index(condition=models.Q(('is_ios_available', True)), fields=['is_ios_available'], name='benefit_ios_available_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='campaign_search_vector_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
index=models.Index(condition=models.Q(('end_at__isnull', False), ('start_at__isnull', False)), fields=['start_at', 'end_at'], name='campaign_active_partial_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='game',
|
|
||||||
index=models.Index(condition=models.Q(('owner__isnull', False)), fields=['owner'], name='game_owner_partial_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
index=models.Index(fields=['campaign', 'start_at', 'required_minutes_watched'], name='twitch_time_campaig_4cc3b7_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
constraint=models.CheckConstraint(condition=models.Q(('start_at__isnull', True), ('end_at__isnull', True), ('end_at__gt', models.F('start_at')), _connector='OR'), name='campaign_valid_date_range'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
constraint=models.CheckConstraint(condition=models.Q(('start_at__isnull', True), ('end_at__isnull', True), ('end_at__gt', models.F('start_at')), _connector='OR'), name='drop_valid_date_range'),
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
constraint=models.CheckConstraint(condition=models.Q(('required_minutes_watched__isnull', True), ('required_minutes_watched__gt', 0), _connector='OR'), name='drop_positive_minutes'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-09-04 22:53
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0009_postgresql_optimizations_fixed'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='id',
|
|
||||||
field=models.CharField(help_text='Unique Twitch identifier for the benefit.', max_length=255, primary_key=True, serialize=False),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='id',
|
|
||||||
field=models.CharField(max_length=255, primary_key=True, serialize=False, verbose_name='Game ID'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='id',
|
|
||||||
field=models.CharField(help_text='Unique Twitch identifier for the time-based drop.', max_length=255, primary_key=True, serialize=False),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-09-04 23:03
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0010_alter_dropbenefit_id_alter_game_id_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveConstraint(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='drop_positive_minutes',
|
|
||||||
),
|
|
||||||
migrations.AddConstraint(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
constraint=models.CheckConstraint(condition=models.Q(('required_minutes_watched__isnull', True), ('required_minutes_watched__gte', 0), _connector='OR'), name='drop_positive_minutes'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-09-05 11:36
|
|
||||||
|
|
||||||
import django.contrib.postgres.indexes
|
|
||||||
import django.contrib.postgres.search
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0011_remove_timebaseddrop_drop_positive_minutes_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='search_vector',
|
|
||||||
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='game',
|
|
||||||
name='search_vector',
|
|
||||||
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='organization',
|
|
||||||
name='search_vector',
|
|
||||||
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='search_vector',
|
|
||||||
field=django.contrib.postgres.search.SearchVectorField(blank=True, null=True),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='benefit_search_vector_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='game',
|
|
||||||
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='game_search_vector_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='organization',
|
|
||||||
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='org_search_vector_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='drop_search_vector_idx'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
# Generated by Django 5.2.5 on 2025-09-08 17:08
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0012_dropbenefit_search_vector_game_search_vector_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='allow_is_enabled',
|
|
||||||
field=models.BooleanField(default=True, help_text='Whether the campaign allows participation.'),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Channel',
|
|
||||||
fields=[
|
|
||||||
('id', models.CharField(help_text='The unique Twitch identifier for the channel.', max_length=255, primary_key=True, serialize=False, verbose_name='Channel ID')),
|
|
||||||
('name', models.CharField(db_index=True, help_text='The lowercase username of the channel.', max_length=255, verbose_name='Username')),
|
|
||||||
('display_name', models.CharField(db_index=True, help_text='The display name of the channel (with proper capitalization).', max_length=255, verbose_name='Display Name')),
|
|
||||||
('added_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Timestamp when this channel record was created.')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, help_text='Timestamp when this channel record was last updated.')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['display_name'],
|
|
||||||
'indexes': [models.Index(fields=['name'], name='twitch_chan_name_15d566_idx'), models.Index(fields=['display_name'], name='twitch_chan_display_2bf213_idx')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='allow_channels',
|
|
||||||
field=models.ManyToManyField(blank=True, help_text='Channels that are allowed to participate in this campaign.', related_name='allowed_campaigns', to='twitch.channel'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
# Generated by Django 5.2.6 on 2025-09-12 22:03
|
|
||||||
|
|
||||||
import django.db.models.manager
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0013_dropcampaign_allow_is_enabled_channel_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='channel',
|
|
||||||
options={'base_manager_name': 'prefetch_manager', 'ordering': ['display_name']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='dropbenefit',
|
|
||||||
options={'base_manager_name': 'prefetch_manager', 'ordering': ['-created_at']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='dropbenefitedge',
|
|
||||||
options={'base_manager_name': 'prefetch_manager'},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='dropcampaign',
|
|
||||||
options={'base_manager_name': 'prefetch_manager', 'ordering': ['-start_at']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='game',
|
|
||||||
options={'base_manager_name': 'prefetch_manager', 'ordering': ['display_name']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='notificationsubscription',
|
|
||||||
options={'base_manager_name': 'prefetch_manager'},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='organization',
|
|
||||||
options={'base_manager_name': 'prefetch_manager', 'ordering': ['name']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='timebaseddrop',
|
|
||||||
options={'base_manager_name': 'prefetch_manager', 'ordering': ['start_at']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name='channel',
|
|
||||||
managers=[
|
|
||||||
('objects', django.db.models.manager.Manager()),
|
|
||||||
('prefetch_manager', django.db.models.manager.Manager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name='dropbenefit',
|
|
||||||
managers=[
|
|
||||||
('objects', django.db.models.manager.Manager()),
|
|
||||||
('prefetch_manager', django.db.models.manager.Manager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name='dropbenefitedge',
|
|
||||||
managers=[
|
|
||||||
('objects', django.db.models.manager.Manager()),
|
|
||||||
('prefetch_manager', django.db.models.manager.Manager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name='dropcampaign',
|
|
||||||
managers=[
|
|
||||||
('objects', django.db.models.manager.Manager()),
|
|
||||||
('prefetch_manager', django.db.models.manager.Manager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name='game',
|
|
||||||
managers=[
|
|
||||||
('objects', django.db.models.manager.Manager()),
|
|
||||||
('prefetch_manager', django.db.models.manager.Manager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name='notificationsubscription',
|
|
||||||
managers=[
|
|
||||||
('objects', django.db.models.manager.Manager()),
|
|
||||||
('prefetch_manager', django.db.models.manager.Manager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name='organization',
|
|
||||||
managers=[
|
|
||||||
('objects', django.db.models.manager.Manager()),
|
|
||||||
('prefetch_manager', django.db.models.manager.Manager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AlterModelManagers(
|
|
||||||
name='timebaseddrop',
|
|
||||||
managers=[
|
|
||||||
('objects', django.db.models.manager.Manager()),
|
|
||||||
('prefetch_manager', django.db.models.manager.Manager()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
# Generated by Django 5.2.6 on 2025-09-12 22:18
|
|
||||||
|
|
||||||
import auto_prefetch
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0014_alter_channel_options_alter_dropbenefit_options_and_more'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
name='benefit',
|
|
||||||
field=auto_prefetch.ForeignKey(help_text='The benefit in this relationship.', on_delete=django.db.models.deletion.CASCADE, to='twitch.dropbenefit'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefitedge',
|
|
||||||
name='drop',
|
|
||||||
field=auto_prefetch.ForeignKey(help_text='The time-based drop in this relationship.', on_delete=django.db.models.deletion.CASCADE, to='twitch.timebaseddrop'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='game',
|
|
||||||
field=auto_prefetch.ForeignKey(help_text='Game associated with this campaign.', on_delete=django.db.models.deletion.CASCADE, related_name='drop_campaigns', to='twitch.game', verbose_name='Game'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='owner',
|
|
||||||
field=auto_prefetch.ForeignKey(blank=True, help_text='The organization that owns this game.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='games', to='twitch.organization', verbose_name='Organization'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='notificationsubscription',
|
|
||||||
name='game',
|
|
||||||
field=auto_prefetch.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='twitch.game'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='notificationsubscription',
|
|
||||||
name='organization',
|
|
||||||
field=auto_prefetch.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='twitch.organization'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='notificationsubscription',
|
|
||||||
name='user',
|
|
||||||
field=auto_prefetch.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='timebaseddrop',
|
|
||||||
name='campaign',
|
|
||||||
field=auto_prefetch.ForeignKey(help_text='The campaign this drop belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='time_based_drops', to='twitch.dropcampaign'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
"""Add local image FileFields to models for caching Twitch images."""
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("twitch", "0015_alter_dropbenefitedge_benefit_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="game",
|
|
||||||
name="box_art_file",
|
|
||||||
field=models.FileField(blank=True, null=True, upload_to="games/box_art/"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="dropcampaign",
|
|
||||||
name="image_file",
|
|
||||||
field=models.FileField(blank=True, null=True, upload_to="campaigns/images/"),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="dropbenefit",
|
|
||||||
name="image_file",
|
|
||||||
field=models.FileField(blank=True, null=True, upload_to="benefits/images/"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# Generated by Django 5.2.6 on 2025-09-13 00:49
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('twitch', '0016_add_local_image_fields'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropbenefit',
|
|
||||||
name='image_file',
|
|
||||||
field=models.FileField(blank=True, help_text='Locally cached benefit image served from this site.', null=True, upload_to='benefits/images/'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='dropcampaign',
|
|
||||||
name='image_file',
|
|
||||||
field=models.FileField(blank=True, help_text='Locally cached campaign image served from this site.', null=True, upload_to='campaigns/images/'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='game',
|
|
||||||
name='box_art_file',
|
|
||||||
field=models.FileField(blank=True, help_text='Locally cached box art image served from this site.', null=True, upload_to='games/box_art/'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
213
twitch/models.py
213
twitch/models.py
|
|
@ -1,18 +1,11 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from typing import TYPE_CHECKING, ClassVar
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
|
||||||
|
|
||||||
import auto_prefetch
|
|
||||||
from django.contrib.postgres.indexes import GinIndex
|
|
||||||
from django.contrib.postgres.search import SearchVectorField
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from accounts.models import User
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
|
@ -20,17 +13,15 @@ logger: logging.Logger = logging.getLogger("ttvdrops")
|
||||||
|
|
||||||
|
|
||||||
# MARK: Organization
|
# MARK: Organization
|
||||||
class Organization(auto_prefetch.Model):
|
class Organization(models.Model):
|
||||||
"""Represents an organization on Twitch that can own drop campaigns."""
|
"""Represents an organization on Twitch that can own drop campaigns."""
|
||||||
|
|
||||||
id = models.CharField(
|
id = models.TextField(
|
||||||
max_length=255,
|
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
verbose_name="Organization ID",
|
verbose_name="Organization ID",
|
||||||
help_text="The unique Twitch identifier for the organization.",
|
help_text="The unique Twitch identifier for the organization.",
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.TextField(
|
||||||
max_length=255,
|
|
||||||
db_index=True,
|
db_index=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
verbose_name="Name",
|
verbose_name="Name",
|
||||||
|
|
@ -47,16 +38,10 @@ class Organization(auto_prefetch.Model):
|
||||||
help_text="Timestamp when this organization record was last updated.",
|
help_text="Timestamp when this organization record was last updated.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# PostgreSQL full-text search field
|
class Meta:
|
||||||
search_vector = SearchVectorField(null=True, blank=True)
|
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
indexes: ClassVar[list] = [
|
indexes: ClassVar[list] = [
|
||||||
# Regular B-tree index for name lookups
|
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
# Full-text search index (GIN works with SearchVectorField)
|
|
||||||
GinIndex(fields=["search_vector"], name="org_search_vector_idx"),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
|
@ -65,11 +50,11 @@ class Organization(auto_prefetch.Model):
|
||||||
|
|
||||||
|
|
||||||
# MARK: Game
|
# MARK: Game
|
||||||
class Game(auto_prefetch.Model):
|
class Game(models.Model):
|
||||||
"""Represents a game on Twitch."""
|
"""Represents a game on Twitch."""
|
||||||
|
|
||||||
id = models.CharField(max_length=255, primary_key=True, verbose_name="Game ID")
|
id = models.TextField(primary_key=True, verbose_name="Game ID")
|
||||||
slug = models.CharField(
|
slug = models.TextField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
|
|
@ -77,15 +62,13 @@ class Game(auto_prefetch.Model):
|
||||||
verbose_name="Slug",
|
verbose_name="Slug",
|
||||||
help_text="Short unique identifier for the game.",
|
help_text="Short unique identifier for the game.",
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.TextField(
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name="Name",
|
verbose_name="Name",
|
||||||
)
|
)
|
||||||
display_name = models.CharField(
|
display_name = models.TextField(
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
|
|
@ -97,7 +80,7 @@ class Game(auto_prefetch.Model):
|
||||||
default="",
|
default="",
|
||||||
verbose_name="Box art URL",
|
verbose_name="Box art URL",
|
||||||
)
|
)
|
||||||
# Locally cached image file for the game's box art
|
|
||||||
box_art_file = models.FileField(
|
box_art_file = models.FileField(
|
||||||
upload_to="games/box_art/",
|
upload_to="games/box_art/",
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
@ -105,10 +88,7 @@ class Game(auto_prefetch.Model):
|
||||||
help_text="Locally cached box art image served from this site.",
|
help_text="Locally cached box art image served from this site.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# PostgreSQL full-text search field
|
owner = models.ForeignKey(
|
||||||
search_vector = SearchVectorField(null=True, blank=True)
|
|
||||||
|
|
||||||
owner = auto_prefetch.ForeignKey(
|
|
||||||
Organization,
|
Organization,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name="games",
|
related_name="games",
|
||||||
|
|
@ -128,16 +108,12 @@ class Game(auto_prefetch.Model):
|
||||||
help_text="Timestamp when this game record was last updated.",
|
help_text="Timestamp when this game record was last updated.",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
class Meta:
|
||||||
ordering = ["display_name"]
|
ordering = ["display_name"]
|
||||||
indexes: ClassVar[list] = [
|
indexes: ClassVar[list] = [
|
||||||
models.Index(fields=["slug"]),
|
|
||||||
# Regular B-tree indexes for name lookups
|
|
||||||
models.Index(fields=["display_name"]),
|
models.Index(fields=["display_name"]),
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
# Full-text search index (GIN works with SearchVectorField)
|
models.Index(fields=["slug"]),
|
||||||
GinIndex(fields=["search_vector"], name="game_search_vector_idx"),
|
|
||||||
# Partial index for games with owners only
|
|
||||||
models.Index(fields=["owner"], condition=models.Q(owner__isnull=False), name="game_owner_partial_idx"),
|
models.Index(fields=["owner"], condition=models.Q(owner__isnull=False), name="game_owner_partial_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -157,36 +133,6 @@ class Game(auto_prefetch.Model):
|
||||||
"""Return all organizations that own games with campaigns for this game."""
|
"""Return all organizations that own games with campaigns for this game."""
|
||||||
return Organization.objects.filter(games__drop_campaigns__game=self).distinct()
|
return Organization.objects.filter(games__drop_campaigns__game=self).distinct()
|
||||||
|
|
||||||
@property
|
|
||||||
def box_art_base_url(self) -> str:
|
|
||||||
"""Return the base box art URL without Twitch size suffixes."""
|
|
||||||
if not self.box_art:
|
|
||||||
return ""
|
|
||||||
parts = urlsplit(self.box_art)
|
|
||||||
path = re.sub(
|
|
||||||
r"(-\d+x\d+)(\.(?:jpg|jpeg|png|gif|webp))$",
|
|
||||||
r"\2",
|
|
||||||
parts.path,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def box_art_best_url(self) -> str:
|
|
||||||
"""Return the best available URL for the game's box art.
|
|
||||||
|
|
||||||
Preference order:
|
|
||||||
1) Local cached file (MEDIA)
|
|
||||||
2) Remote Twitch base URL
|
|
||||||
3) Empty string
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if self.box_art_file and getattr(self.box_art_file, "url", None):
|
|
||||||
return self.box_art_file.url
|
|
||||||
except (AttributeError, OSError, ValueError) as exc: # storage might not be configured in some contexts
|
|
||||||
logger.debug("Failed to resolve Game.box_art_file url: %s", exc)
|
|
||||||
return self.box_art_base_url
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def get_game_name(self) -> str:
|
def get_game_name(self) -> str:
|
||||||
"""Return the best available name for the game."""
|
"""Return the best available name for the game."""
|
||||||
|
|
@ -207,7 +153,7 @@ class Game(auto_prefetch.Model):
|
||||||
|
|
||||||
|
|
||||||
# MARK: TwitchGame
|
# MARK: TwitchGame
|
||||||
class TwitchGameData(auto_prefetch.Model):
|
class TwitchGameData(models.Model):
|
||||||
"""Represents game metadata returned from the Twitch API.
|
"""Represents game metadata returned from the Twitch API.
|
||||||
|
|
||||||
This mirrors the public Twitch API fields for a game and is tied to the local `Game` model where possible.
|
This mirrors the public Twitch API fields for a game and is tied to the local `Game` model where possible.
|
||||||
|
|
@ -220,8 +166,8 @@ class TwitchGameData(auto_prefetch.Model):
|
||||||
igdb_id: Optional IGDB id for the game
|
igdb_id: Optional IGDB id for the game
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = models.CharField(max_length=255, primary_key=True, verbose_name="Twitch Game ID")
|
id = models.TextField(primary_key=True, verbose_name="Twitch Game ID")
|
||||||
game = auto_prefetch.ForeignKey(
|
game = models.ForeignKey(
|
||||||
Game,
|
Game,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
related_name="twitch_game_data",
|
related_name="twitch_game_data",
|
||||||
|
|
@ -231,7 +177,7 @@ class TwitchGameData(auto_prefetch.Model):
|
||||||
help_text="Optional link to the local Game record for this Twitch game.",
|
help_text="Optional link to the local Game record for this Twitch game.",
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(max_length=255, blank=True, default="", db_index=True, verbose_name="Name")
|
name = models.TextField(blank=True, default="", db_index=True, verbose_name="Name")
|
||||||
box_art_url = models.URLField(
|
box_art_url = models.URLField(
|
||||||
max_length=500,
|
max_length=500,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
@ -239,39 +185,36 @@ class TwitchGameData(auto_prefetch.Model):
|
||||||
verbose_name="Box art URL",
|
verbose_name="Box art URL",
|
||||||
help_text="URL template with {width}x{height} placeholders for the box art image.",
|
help_text="URL template with {width}x{height} placeholders for the box art image.",
|
||||||
)
|
)
|
||||||
igdb_id = models.CharField(max_length=255, blank=True, default="", verbose_name="IGDB ID")
|
igdb_id = models.TextField(blank=True, default="", verbose_name="IGDB ID")
|
||||||
|
|
||||||
added_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text="Record creation time.")
|
added_at = models.DateTimeField(auto_now_add=True, db_index=True, help_text="Record creation time.")
|
||||||
updated_at = models.DateTimeField(auto_now=True, help_text="Record last update time.")
|
updated_at = models.DateTimeField(auto_now=True, help_text="Record last update time.")
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
class Meta:
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
indexes: ClassVar[list] = [
|
indexes: ClassVar[list] = [
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self) -> str: # pragma: no cover - trivial
|
def __str__(self) -> str:
|
||||||
return self.name or self.id
|
return self.name or self.id
|
||||||
|
|
||||||
|
|
||||||
# MARK: Channel
|
# MARK: Channel
|
||||||
class Channel(auto_prefetch.Model):
|
class Channel(models.Model):
|
||||||
"""Represents a Twitch channel that can participate in drop campaigns."""
|
"""Represents a Twitch channel that can participate in drop campaigns."""
|
||||||
|
|
||||||
id = models.CharField(
|
id = models.TextField(
|
||||||
max_length=255,
|
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
verbose_name="Channel ID",
|
verbose_name="Channel ID",
|
||||||
help_text="The unique Twitch identifier for the channel.",
|
help_text="The unique Twitch identifier for the channel.",
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.TextField(
|
||||||
max_length=255,
|
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name="Username",
|
verbose_name="Username",
|
||||||
help_text="The lowercase username of the channel.",
|
help_text="The lowercase username of the channel.",
|
||||||
)
|
)
|
||||||
display_name = models.CharField(
|
display_name = models.TextField(
|
||||||
max_length=255,
|
|
||||||
db_index=True,
|
db_index=True,
|
||||||
verbose_name="Display Name",
|
verbose_name="Display Name",
|
||||||
help_text="The display name of the channel (with proper capitalization).",
|
help_text="The display name of the channel (with proper capitalization).",
|
||||||
|
|
@ -287,7 +230,7 @@ class Channel(auto_prefetch.Model):
|
||||||
help_text="Timestamp when this channel record was last updated.",
|
help_text="Timestamp when this channel record was last updated.",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
class Meta:
|
||||||
ordering = ["display_name"]
|
ordering = ["display_name"]
|
||||||
indexes: ClassVar[list] = [
|
indexes: ClassVar[list] = [
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
|
|
@ -300,16 +243,14 @@ class Channel(auto_prefetch.Model):
|
||||||
|
|
||||||
|
|
||||||
# MARK: DropCampaign
|
# MARK: DropCampaign
|
||||||
class DropCampaign(auto_prefetch.Model):
|
class DropCampaign(models.Model):
|
||||||
"""Represents a Twitch drop campaign."""
|
"""Represents a Twitch drop campaign."""
|
||||||
|
|
||||||
id = models.CharField(
|
id = models.TextField(
|
||||||
max_length=255,
|
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
help_text="Unique Twitch identifier for the campaign.",
|
help_text="Unique Twitch identifier for the campaign.",
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.TextField(
|
||||||
max_length=255,
|
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Name of the drop campaign.",
|
help_text="Name of the drop campaign.",
|
||||||
)
|
)
|
||||||
|
|
@ -335,7 +276,6 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
default="",
|
default="",
|
||||||
help_text="URL to an image representing the campaign.",
|
help_text="URL to an image representing the campaign.",
|
||||||
)
|
)
|
||||||
# Locally cached campaign image
|
|
||||||
image_file = models.FileField(
|
image_file = models.FileField(
|
||||||
upload_to="campaigns/images/",
|
upload_to="campaigns/images/",
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
@ -369,10 +309,7 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
help_text="Channels that are allowed to participate in this campaign.",
|
help_text="Channels that are allowed to participate in this campaign.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# PostgreSQL full-text search field
|
game = models.ForeignKey(
|
||||||
search_vector = SearchVectorField(null=True, blank=True)
|
|
||||||
|
|
||||||
game = auto_prefetch.ForeignKey(
|
|
||||||
Game,
|
Game,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="drop_campaigns",
|
related_name="drop_campaigns",
|
||||||
|
|
@ -390,7 +327,7 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
help_text="Timestamp when this campaign record was last updated.",
|
help_text="Timestamp when this campaign record was last updated.",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
class Meta:
|
||||||
ordering = ["-start_at"]
|
ordering = ["-start_at"]
|
||||||
constraints = [
|
constraints = [
|
||||||
# Ensure end_at is after start_at when both are set
|
# Ensure end_at is after start_at when both are set
|
||||||
|
|
@ -400,13 +337,8 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
indexes: ClassVar[list] = [
|
indexes: ClassVar[list] = [
|
||||||
# Regular B-tree index for campaign name lookups
|
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
# Full-text search index (GIN works with SearchVectorField)
|
|
||||||
GinIndex(fields=["search_vector"], name="campaign_search_vector_idx"),
|
|
||||||
# Composite index for time range queries
|
|
||||||
models.Index(fields=["start_at", "end_at"]),
|
models.Index(fields=["start_at", "end_at"]),
|
||||||
# Partial index for active campaigns
|
|
||||||
models.Index(fields=["start_at", "end_at"], condition=models.Q(start_at__isnull=False, end_at__isnull=False), name="campaign_active_partial_idx"),
|
models.Index(fields=["start_at", "end_at"], condition=models.Q(start_at__isnull=False, end_at__isnull=False), name="campaign_active_partial_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -462,16 +394,14 @@ class DropCampaign(auto_prefetch.Model):
|
||||||
|
|
||||||
|
|
||||||
# MARK: DropBenefit
|
# MARK: DropBenefit
|
||||||
class DropBenefit(auto_prefetch.Model):
|
class DropBenefit(models.Model):
|
||||||
"""Represents a benefit that can be earned from a drop."""
|
"""Represents a benefit that can be earned from a drop."""
|
||||||
|
|
||||||
id = models.CharField(
|
id = models.TextField(
|
||||||
max_length=255,
|
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
help_text="Unique Twitch identifier for the benefit.",
|
help_text="Unique Twitch identifier for the benefit.",
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.TextField(
|
||||||
max_length=255,
|
|
||||||
db_index=True,
|
db_index=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
default="N/A",
|
default="N/A",
|
||||||
|
|
@ -483,7 +413,6 @@ class DropBenefit(auto_prefetch.Model):
|
||||||
default="",
|
default="",
|
||||||
help_text="URL to the benefit's image asset.",
|
help_text="URL to the benefit's image asset.",
|
||||||
)
|
)
|
||||||
# Locally cached benefit image
|
|
||||||
image_file = models.FileField(
|
image_file = models.FileField(
|
||||||
upload_to="benefits/images/",
|
upload_to="benefits/images/",
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
@ -505,7 +434,7 @@ class DropBenefit(auto_prefetch.Model):
|
||||||
default=False,
|
default=False,
|
||||||
help_text="Whether the benefit is available on iOS.",
|
help_text="Whether the benefit is available on iOS.",
|
||||||
)
|
)
|
||||||
distribution_type = models.CharField(
|
distribution_type = models.TextField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|
@ -513,9 +442,6 @@ class DropBenefit(auto_prefetch.Model):
|
||||||
help_text="Type of distribution for this benefit.",
|
help_text="Type of distribution for this benefit.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# PostgreSQL full-text search field
|
|
||||||
search_vector = SearchVectorField(null=True, blank=True)
|
|
||||||
|
|
||||||
added_at = models.DateTimeField(
|
added_at = models.DateTimeField(
|
||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
|
|
@ -526,16 +452,12 @@ class DropBenefit(auto_prefetch.Model):
|
||||||
help_text="Timestamp when this benefit record was last updated.",
|
help_text="Timestamp when this benefit record was last updated.",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes: ClassVar[list] = [
|
indexes: ClassVar[list] = [
|
||||||
# Regular B-tree index for benefit name lookups
|
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
# Full-text search index (GIN works with SearchVectorField)
|
|
||||||
GinIndex(fields=["search_vector"], name="benefit_search_vector_idx"),
|
|
||||||
models.Index(fields=["created_at"]),
|
models.Index(fields=["created_at"]),
|
||||||
models.Index(fields=["distribution_type"]),
|
models.Index(fields=["distribution_type"]),
|
||||||
# Partial index for iOS available benefits
|
|
||||||
models.Index(fields=["is_ios_available"], condition=models.Q(is_ios_available=True), name="benefit_ios_available_idx"),
|
models.Index(fields=["is_ios_available"], condition=models.Q(is_ios_available=True), name="benefit_ios_available_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -543,28 +465,16 @@ class DropBenefit(auto_prefetch.Model):
|
||||||
"""Return a string representation of the drop benefit."""
|
"""Return a string representation of the drop benefit."""
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@property
|
|
||||||
def image_best_url(self) -> str:
|
|
||||||
"""Return the best available URL for the benefit image (local first)."""
|
|
||||||
try:
|
|
||||||
if self.image_file and getattr(self.image_file, "url", None):
|
|
||||||
return self.image_file.url
|
|
||||||
except (AttributeError, OSError, ValueError) as exc:
|
|
||||||
logger.debug("Failed to resolve DropBenefit.image_file url: %s", exc)
|
|
||||||
return self.image_asset_url or ""
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: TimeBasedDrop
|
# MARK: TimeBasedDrop
|
||||||
class TimeBasedDrop(auto_prefetch.Model):
|
class TimeBasedDrop(models.Model):
|
||||||
"""Represents a time-based drop in a drop campaign."""
|
"""Represents a time-based drop in a drop campaign."""
|
||||||
|
|
||||||
id = models.CharField(
|
id = models.TextField(
|
||||||
max_length=255,
|
|
||||||
primary_key=True,
|
primary_key=True,
|
||||||
help_text="Unique Twitch identifier for the time-based drop.",
|
help_text="Unique Twitch identifier for the time-based drop.",
|
||||||
)
|
)
|
||||||
name = models.CharField(
|
name = models.TextField(
|
||||||
max_length=255,
|
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Name of the time-based drop.",
|
help_text="Name of the time-based drop.",
|
||||||
)
|
)
|
||||||
|
|
@ -591,11 +501,8 @@ class TimeBasedDrop(auto_prefetch.Model):
|
||||||
help_text="Datetime when this drop expires.",
|
help_text="Datetime when this drop expires.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# PostgreSQL full-text search field
|
|
||||||
search_vector = SearchVectorField(null=True, blank=True)
|
|
||||||
|
|
||||||
# Foreign keys
|
# Foreign keys
|
||||||
campaign = auto_prefetch.ForeignKey(
|
campaign = models.ForeignKey(
|
||||||
DropCampaign,
|
DropCampaign,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="time_based_drops",
|
related_name="time_based_drops",
|
||||||
|
|
@ -618,7 +525,7 @@ class TimeBasedDrop(auto_prefetch.Model):
|
||||||
help_text="Timestamp when this time-based drop record was last updated.",
|
help_text="Timestamp when this time-based drop record was last updated.",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
class Meta:
|
||||||
ordering = ["start_at"]
|
ordering = ["start_at"]
|
||||||
constraints = [
|
constraints = [
|
||||||
# Ensure end_at is after start_at when both are set
|
# Ensure end_at is after start_at when both are set
|
||||||
|
|
@ -632,13 +539,9 @@ class TimeBasedDrop(auto_prefetch.Model):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
indexes: ClassVar[list] = [
|
indexes: ClassVar[list] = [
|
||||||
# Regular B-tree index for drop name lookups
|
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
# Full-text search index (GIN works with SearchVectorField)
|
|
||||||
GinIndex(fields=["search_vector"], name="drop_search_vector_idx"),
|
|
||||||
models.Index(fields=["start_at", "end_at"]),
|
models.Index(fields=["start_at", "end_at"]),
|
||||||
models.Index(fields=["required_minutes_watched"]),
|
models.Index(fields=["required_minutes_watched"]),
|
||||||
# Covering index for common queries (includes campaign_id from FK)
|
|
||||||
models.Index(fields=["campaign", "start_at", "required_minutes_watched"]),
|
models.Index(fields=["campaign", "start_at", "required_minutes_watched"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -648,15 +551,15 @@ class TimeBasedDrop(auto_prefetch.Model):
|
||||||
|
|
||||||
|
|
||||||
# MARK: DropBenefitEdge
|
# MARK: DropBenefitEdge
|
||||||
class DropBenefitEdge(auto_prefetch.Model):
|
class DropBenefitEdge(models.Model):
|
||||||
"""Represents the relationship between a TimeBasedDrop and a DropBenefit."""
|
"""Represents the relationship between a TimeBasedDrop and a DropBenefit."""
|
||||||
|
|
||||||
drop = auto_prefetch.ForeignKey(
|
drop = models.ForeignKey(
|
||||||
TimeBasedDrop,
|
TimeBasedDrop,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
help_text="The time-based drop in this relationship.",
|
help_text="The time-based drop in this relationship.",
|
||||||
)
|
)
|
||||||
benefit = auto_prefetch.ForeignKey(
|
benefit = models.ForeignKey(
|
||||||
DropBenefit,
|
DropBenefit,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
help_text="The benefit in this relationship.",
|
help_text="The benefit in this relationship.",
|
||||||
|
|
@ -676,7 +579,7 @@ class DropBenefitEdge(auto_prefetch.Model):
|
||||||
help_text="Timestamp when this drop-benefit edge was last updated.",
|
help_text="Timestamp when this drop-benefit edge was last updated.",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(fields=("drop", "benefit"), name="unique_drop_benefit"),
|
models.UniqueConstraint(fields=("drop", "benefit"), name="unique_drop_benefit"),
|
||||||
]
|
]
|
||||||
|
|
@ -687,31 +590,3 @@ class DropBenefitEdge(auto_prefetch.Model):
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Return a string representation of the drop benefit edge."""
|
"""Return a string representation of the drop benefit edge."""
|
||||||
return f"{self.drop.name} - {self.benefit.name}"
|
return f"{self.drop.name} - {self.benefit.name}"
|
||||||
|
|
||||||
|
|
||||||
# MARK: NotificationSubscription
|
|
||||||
class NotificationSubscription(auto_prefetch.Model):
|
|
||||||
"""Users can subscribe to games to get notified."""
|
|
||||||
|
|
||||||
user = auto_prefetch.ForeignKey(User, on_delete=models.CASCADE)
|
|
||||||
game = auto_prefetch.ForeignKey(Game, null=True, blank=True, on_delete=models.CASCADE)
|
|
||||||
organization = auto_prefetch.ForeignKey(Organization, null=True, blank=True, on_delete=models.CASCADE)
|
|
||||||
|
|
||||||
notify_found = models.BooleanField(default=False)
|
|
||||||
notify_live = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
added_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta(auto_prefetch.Model.Meta):
|
|
||||||
unique_together: ClassVar[list[tuple[str, str]]] = [
|
|
||||||
("user", "game"),
|
|
||||||
("user", "organization"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
if self.game:
|
|
||||||
return f"{self.user} subscription to game: {self.game.display_name}"
|
|
||||||
if self.organization:
|
|
||||||
return f"{self.user} subscription to organization: {self.organization.name}"
|
|
||||||
return f"{self.user} subscription"
|
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,8 @@ urlpatterns: list[URLPattern] = [
|
||||||
path("games/", views.GamesGridView.as_view(), name="game_list"),
|
path("games/", views.GamesGridView.as_view(), name="game_list"),
|
||||||
path("games/list/", views.GamesListView.as_view(), name="game_list_simple"),
|
path("games/list/", views.GamesListView.as_view(), name="game_list_simple"),
|
||||||
path("games/<str:pk>/", views.GameDetailView.as_view(), name="game_detail"),
|
path("games/<str:pk>/", views.GameDetailView.as_view(), name="game_detail"),
|
||||||
path("games/<str:game_id>/subscribe/", views.subscribe_game_notifications, name="subscribe_notifications"),
|
|
||||||
path("organizations/", views.OrgListView.as_view(), name="org_list"),
|
path("organizations/", views.OrgListView.as_view(), name="org_list"),
|
||||||
path("organizations/<str:pk>/", views.OrgDetailView.as_view(), name="organization_detail"),
|
path("organizations/<str:pk>/", views.OrgDetailView.as_view(), name="organization_detail"),
|
||||||
path("organizations/<str:org_id>/subscribe/", views.subscribe_org_notifications, name="subscribe_org_notifications"),
|
|
||||||
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
|
path("channels/", views.ChannelListView.as_view(), name="channel_list"),
|
||||||
path("channels/<str:pk>/", views.ChannelDetailView.as_view(), name="channel_detail"),
|
path("channels/<str:pk>/", views.ChannelDetailView.as_view(), name="channel_detail"),
|
||||||
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
|
path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# Utility package for twitch app
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import mimetypes
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_filename(name: str) -> str:
|
|
||||||
"""Return a filesystem-safe filename."""
|
|
||||||
name = re.sub(r"[^A-Za-z0-9._-]", "_", name)
|
|
||||||
return name[:150] or "file"
|
|
||||||
|
|
||||||
|
|
||||||
def _guess_extension(url: str, content_type: str | None) -> str:
|
|
||||||
"""Guess a file extension from URL or content-type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Source URL.
|
|
||||||
content_type: Optional content type from HTTP response.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
File extension including dot, like ".png".
|
|
||||||
"""
|
|
||||||
parsed = urlparse(url)
|
|
||||||
ext = Path(parsed.path).suffix.lower()
|
|
||||||
if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
|
||||||
return ext
|
|
||||||
if content_type:
|
|
||||||
guessed = mimetypes.guess_extension(content_type.split(";")[0].strip())
|
|
||||||
if guessed:
|
|
||||||
return guessed
|
|
||||||
return ".bin"
|
|
||||||
|
|
||||||
|
|
||||||
def cache_remote_image(url: str, subdir: str, *, timeout: float = 10.0) -> str | None:
|
|
||||||
"""Download a remote image and save it under MEDIA_ROOT, returning storage path.
|
|
||||||
|
|
||||||
The file name is the SHA256 of the content to de-duplicate downloads.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Remote image URL.
|
|
||||||
subdir: Sub-directory under MEDIA_ROOT to store the file.
|
|
||||||
timeout: Network timeout in seconds.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Relative storage path (under MEDIA_ROOT) suitable for assigning to FileField.name,
|
|
||||||
or None if the operation failed.
|
|
||||||
"""
|
|
||||||
url = (url or "").strip()
|
|
||||||
if not url or not url.startswith(("http://", "https://")):
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Enforce allowed schemes at runtime too
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if parsed.scheme not in {"http", "https"}:
|
|
||||||
return None
|
|
||||||
req = Request(url, headers={"User-Agent": "TTVDrops/1.0"}) # noqa: S310
|
|
||||||
# nosec: B310 - urlopen allowed because scheme is validated (http/https only)
|
|
||||||
with urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
||||||
content: bytes = resp.read()
|
|
||||||
content_type = resp.headers.get("Content-Type")
|
|
||||||
except OSError as exc:
|
|
||||||
logger.debug("Failed to download image %s: %s", url, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not content:
|
|
||||||
return None
|
|
||||||
|
|
||||||
sha = hashlib.sha256(content).hexdigest()
|
|
||||||
ext = _guess_extension(url, content_type)
|
|
||||||
# Shard into two-level directories by hash for scalability
|
|
||||||
shard1, shard2 = sha[:2], sha[2:4]
|
|
||||||
media_subdir = Path(subdir) / shard1 / shard2
|
|
||||||
target_dir: Path = Path(settings.MEDIA_ROOT) / media_subdir
|
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
filename = f"{sha}{ext}"
|
|
||||||
storage_rel_path = str(media_subdir / _sanitize_filename(filename)).replace("\\", "/")
|
|
||||||
storage_abs_path = Path(settings.MEDIA_ROOT) / storage_rel_path
|
|
||||||
|
|
||||||
if not storage_abs_path.exists():
|
|
||||||
try:
|
|
||||||
storage_abs_path.write_bytes(content)
|
|
||||||
except OSError as exc:
|
|
||||||
logger.debug("Failed to write image %s: %s", storage_abs_path, exc)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return storage_rel_path
|
|
||||||
114
twitch/views.py
114
twitch/views.py
|
|
@ -6,28 +6,24 @@ import logging
|
||||||
from collections import OrderedDict, defaultdict
|
from collections import OrderedDict, defaultdict
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
|
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
|
||||||
from django.core.serializers import serialize
|
from django.core.serializers import serialize
|
||||||
from django.db.models import Count, F, Prefetch, Q
|
from django.db.models import Count, F, Prefetch, Q
|
||||||
from django.db.models.functions import Trim
|
from django.db.models.functions import Trim
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.http.response import HttpResponseRedirect
|
from django.shortcuts import render
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
from pygments import highlight
|
from pygments import highlight
|
||||||
from pygments.formatters import HtmlFormatter
|
from pygments.formatters import HtmlFormatter
|
||||||
from pygments.lexers.data import JsonLexer
|
from pygments.lexers.data import JsonLexer
|
||||||
|
|
||||||
from twitch.models import Channel, DropBenefit, DropCampaign, Game, NotificationSubscription, Organization, TimeBasedDrop
|
from twitch.models import Channel, DropBenefit, DropCampaign, Game, Organization, TimeBasedDrop
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.http.response import HttpResponseRedirect
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger(__name__)
|
logger: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -125,12 +121,6 @@ class OrgDetailView(DetailView):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
organization: Organization = self.object
|
organization: Organization = self.object
|
||||||
|
|
||||||
user = self.request.user
|
|
||||||
if not user.is_authenticated:
|
|
||||||
subscription: NotificationSubscription | None = None
|
|
||||||
else:
|
|
||||||
subscription = NotificationSubscription.objects.filter(user=user, organization=organization).first()
|
|
||||||
|
|
||||||
games: QuerySet[Game, Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
|
games: QuerySet[Game, Game] = organization.games.all() # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
serialized_org = serialize(
|
serialized_org = serialize(
|
||||||
|
|
@ -152,7 +142,6 @@ class OrgDetailView(DetailView):
|
||||||
pretty_org_data = json.dumps(org_data[0], indent=4)
|
pretty_org_data = json.dumps(org_data[0], indent=4)
|
||||||
|
|
||||||
context.update({
|
context.update({
|
||||||
"subscription": subscription,
|
|
||||||
"games": games,
|
"games": games,
|
||||||
"org_data": pretty_org_data,
|
"org_data": pretty_org_data,
|
||||||
})
|
})
|
||||||
|
|
@ -439,12 +428,6 @@ class GameDetailView(DetailView):
|
||||||
context: dict[str, Any] = super().get_context_data(**kwargs)
|
context: dict[str, Any] = super().get_context_data(**kwargs)
|
||||||
game: Game = self.get_object()
|
game: Game = self.get_object()
|
||||||
|
|
||||||
user = self.request.user
|
|
||||||
if not user.is_authenticated:
|
|
||||||
subscription: NotificationSubscription | None = None
|
|
||||||
else:
|
|
||||||
subscription = NotificationSubscription.objects.filter(user=user, game=game).first()
|
|
||||||
|
|
||||||
now: datetime.datetime = timezone.now()
|
now: datetime.datetime = timezone.now()
|
||||||
all_campaigns: QuerySet[DropCampaign, DropCampaign] = (
|
all_campaigns: QuerySet[DropCampaign, DropCampaign] = (
|
||||||
DropCampaign.objects.filter(game=game)
|
DropCampaign.objects.filter(game=game)
|
||||||
|
|
@ -514,7 +497,6 @@ class GameDetailView(DetailView):
|
||||||
"active_campaigns": active_campaigns,
|
"active_campaigns": active_campaigns,
|
||||||
"upcoming_campaigns": upcoming_campaigns,
|
"upcoming_campaigns": upcoming_campaigns,
|
||||||
"expired_campaigns": expired_campaigns,
|
"expired_campaigns": expired_campaigns,
|
||||||
"subscription": subscription,
|
|
||||||
"owner": game.owner,
|
"owner": game.owner,
|
||||||
"now": now,
|
"now": now,
|
||||||
"game_data": format_and_color_json(game_data[0]),
|
"game_data": format_and_color_json(game_data[0]),
|
||||||
|
|
@ -558,7 +540,7 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
||||||
if game_id not in campaigns_by_org_game[org_id]["games"]:
|
if game_id not in campaigns_by_org_game[org_id]["games"]:
|
||||||
campaigns_by_org_game[org_id]["games"][game_id] = {
|
campaigns_by_org_game[org_id]["games"][game_id] = {
|
||||||
"name": game_name,
|
"name": game_name,
|
||||||
"box_art": campaign.game.box_art_best_url,
|
"box_art": campaign.game.box_art,
|
||||||
"campaigns": [],
|
"campaigns": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -583,7 +565,6 @@ def dashboard(request: HttpRequest) -> HttpResponse:
|
||||||
|
|
||||||
|
|
||||||
# MARK: /debug/
|
# MARK: /debug/
|
||||||
@login_required
|
|
||||||
def debug_view(request: HttpRequest) -> HttpResponse:
|
def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
"""Debug view showing potentially broken or inconsistent data.
|
"""Debug view showing potentially broken or inconsistent data.
|
||||||
|
|
||||||
|
|
@ -644,95 +625,6 @@ def debug_view(request: HttpRequest) -> HttpResponse:
|
||||||
return render(request, "twitch/debug.html", context)
|
return render(request, "twitch/debug.html", context)
|
||||||
|
|
||||||
|
|
||||||
# MARK: /games/<pk>/subscribe/
|
|
||||||
@login_required
|
|
||||||
def subscribe_game_notifications(request: HttpRequest, game_id: str) -> HttpResponseRedirect:
|
|
||||||
"""Update Game notification for a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The HTTP request.
|
|
||||||
game_id: The game we are updating.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Redirect back to the twitch:game_detail.
|
|
||||||
"""
|
|
||||||
game: Game = get_object_or_404(Game, pk=game_id)
|
|
||||||
if request.method == "POST":
|
|
||||||
notify_found = bool(request.POST.get("notify_found"))
|
|
||||||
notify_live = bool(request.POST.get("notify_live"))
|
|
||||||
|
|
||||||
subscription, created = NotificationSubscription.objects.get_or_create(user=request.user, game=game)
|
|
||||||
|
|
||||||
changes = []
|
|
||||||
if not created:
|
|
||||||
if subscription.notify_found != notify_found:
|
|
||||||
changes.append(f"{'Enabled' if notify_found else 'Disabled'} notification when drop is found")
|
|
||||||
if subscription.notify_live != notify_live:
|
|
||||||
changes.append(f"{'Enabled' if notify_live else 'Disabled'} notification when drop is farmable")
|
|
||||||
|
|
||||||
subscription.notify_found = notify_found
|
|
||||||
subscription.notify_live = notify_live
|
|
||||||
subscription.save()
|
|
||||||
|
|
||||||
if created:
|
|
||||||
message = f"You have subscribed to notifications for {game.display_name}"
|
|
||||||
elif changes:
|
|
||||||
message = "\n".join(changes)
|
|
||||||
else:
|
|
||||||
message = ""
|
|
||||||
|
|
||||||
messages.success(request, message)
|
|
||||||
return redirect("twitch:game_detail", pk=game.id)
|
|
||||||
|
|
||||||
messages.warning(request, "Only POST is available for this view.")
|
|
||||||
return redirect("twitch:game_detail", pk=game.id)
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: /organizations/<pk>/subscribe/
|
|
||||||
@login_required
|
|
||||||
def subscribe_org_notifications(request: HttpRequest, org_id: str) -> HttpResponseRedirect:
|
|
||||||
"""Update Organization notification for a user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: The HTTP request.
|
|
||||||
org_id: The org we are updating.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Redirect back to the twitch:organization_detail.
|
|
||||||
"""
|
|
||||||
organization: Organization = get_object_or_404(Organization, pk=org_id)
|
|
||||||
|
|
||||||
if request.method == "POST":
|
|
||||||
notify_found = bool(request.POST.get("notify_found"))
|
|
||||||
notify_live = bool(request.POST.get("notify_live"))
|
|
||||||
|
|
||||||
subscription, created = NotificationSubscription.objects.get_or_create(user=request.user, organization=organization)
|
|
||||||
|
|
||||||
changes = []
|
|
||||||
if not created:
|
|
||||||
if subscription.notify_found != notify_found:
|
|
||||||
changes.append(f"{'Enabled' if notify_found else 'Disabled'} notification when drop is found")
|
|
||||||
if subscription.notify_live != notify_live:
|
|
||||||
changes.append(f"{'Enabled' if notify_live else 'Disabled'} notification when drop is farmable")
|
|
||||||
|
|
||||||
subscription.notify_found = notify_found
|
|
||||||
subscription.notify_live = notify_live
|
|
||||||
subscription.save()
|
|
||||||
|
|
||||||
if created:
|
|
||||||
message = f"You have subscribed to notifications for this {organization.name}"
|
|
||||||
elif changes:
|
|
||||||
message = "\n".join(changes)
|
|
||||||
else:
|
|
||||||
message = ""
|
|
||||||
|
|
||||||
messages.success(request, message)
|
|
||||||
return redirect("twitch:organization_detail", pk=organization.id)
|
|
||||||
|
|
||||||
messages.warning(request, "Only POST is available for this view.")
|
|
||||||
return redirect("twitch:organization_detail", pk=organization.id)
|
|
||||||
|
|
||||||
|
|
||||||
# MARK: /games/list/
|
# MARK: /games/list/
|
||||||
class GamesListView(GamesGridView):
|
class GamesListView(GamesGridView):
|
||||||
"""List view for games in simple list format."""
|
"""List view for games in simple list format."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue