From 715cbf4bf0fc04ad74711d738f55a3b2146d53c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Mon, 13 Oct 2025 02:07:33 +0200 Subject: [PATCH] Remove bloat --- .github/copilot-instructions.md | 115 --- .vscode/settings.json | 4 + LICENSE | 674 ------------------ accounts/__init__.py | 0 accounts/admin.py | 8 - accounts/apps.py | 11 - accounts/forms.py | 36 - accounts/migrations/0001_initial.py | 44 -- accounts/migrations/__init__.py | 0 accounts/models.py | 16 - accounts/urls.py | 19 - accounts/views.py | 135 ---- compose.yml | 23 - config/settings.py | 49 +- config/urls.py | 4 - conftest.py | 23 - pyproject.toml | 16 +- templates/accounts/login.html | 42 -- templates/accounts/profile.html | 55 -- templates/accounts/signup.html | 40 -- templates/base.html | 12 +- templates/twitch/campaign_detail.html | 49 +- templates/twitch/campaign_list.html | 4 +- templates/twitch/dashboard.html | 2 +- templates/twitch/game_detail.html | 4 +- templates/twitch/games_grid.html | 4 +- twitch/admin.py | 89 --- twitch/management/commands/import_drops.py | 520 +++++++++----- .../commands/update_search_vectors.py | 40 -- twitch/migrations/0001_initial.py | 423 +++++++---- ...e_name_alter_game_display_name_and_more.py | 31 - ...3_alter_dropbenefit_created_at_and_more.py | 28 - ...0004_alter_dropcampaign_end_at_and_more.py | 23 - ...005_alter_timebaseddrop_end_at_and_more.py | 28 - ...ons_alter_dropcampaign_options_and_more.py | 272 ------- .../0007_remove_game_unique_game_slug.py | 17 - ...eated_at_dropcampaign_added_at_and_more.py | 85 --- .../0009_postgresql_optimizations_fixed.py | 52 -- ...r_dropbenefit_id_alter_game_id_and_more.py | 28 - ...aseddrop_drop_positive_minutes_and_more.py | 21 - ...arch_vector_game_search_vector_and_more.py | 51 -- ...paign_allow_is_enabled_channel_and_more.py | 37 - ...ions_alter_dropbenefit_options_and_more.py | 102 --- ..._alter_dropbenefitedge_benefit_and_more.py | 57 -- .../migrations/0016_add_local_image_fields.py | 29 - ...7_alter_dropbenefit_image_file_and_more.py | 28 - twitch/models.py | 213 ++---- twitch/urls.py | 2 - twitch/utils/__init__.py | 3 - twitch/utils/images.py | 97 --- twitch/views.py | 114 +-- 51 files changed, 719 insertions(+), 3060 deletions(-) delete mode 100644 .github/copilot-instructions.md delete mode 100644 LICENSE delete mode 100644 accounts/__init__.py delete mode 100644 accounts/admin.py delete mode 100644 accounts/apps.py delete mode 100644 accounts/forms.py delete mode 100644 accounts/migrations/0001_initial.py delete mode 100644 accounts/migrations/__init__.py delete mode 100644 accounts/models.py delete mode 100644 accounts/urls.py delete mode 100644 accounts/views.py delete mode 100644 compose.yml delete mode 100644 conftest.py delete mode 100644 templates/accounts/login.html delete mode 100644 templates/accounts/profile.html delete mode 100644 templates/accounts/signup.html delete mode 100644 twitch/admin.py delete mode 100644 twitch/management/commands/update_search_vectors.py delete mode 100644 twitch/migrations/0002_game_name_alter_game_display_name_and_more.py delete mode 100644 twitch/migrations/0003_alter_dropbenefit_created_at_and_more.py delete mode 100644 twitch/migrations/0004_alter_dropcampaign_end_at_and_more.py delete mode 100644 twitch/migrations/0005_alter_timebaseddrop_end_at_and_more.py delete mode 100644 twitch/migrations/0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more.py delete mode 100644 twitch/migrations/0007_remove_game_unique_game_slug.py delete mode 100644 twitch/migrations/0008_rename_created_at_dropcampaign_added_at_and_more.py delete mode 100644 twitch/migrations/0009_postgresql_optimizations_fixed.py delete mode 100644 twitch/migrations/0010_alter_dropbenefit_id_alter_game_id_and_more.py delete mode 100644 twitch/migrations/0011_remove_timebaseddrop_drop_positive_minutes_and_more.py delete mode 100644 twitch/migrations/0012_dropbenefit_search_vector_game_search_vector_and_more.py delete mode 100644 twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py delete mode 100644 twitch/migrations/0014_alter_channel_options_alter_dropbenefit_options_and_more.py delete mode 100644 twitch/migrations/0015_alter_dropbenefitedge_benefit_and_more.py delete mode 100644 twitch/migrations/0016_add_local_image_fields.py delete mode 100644 twitch/migrations/0017_alter_dropbenefit_image_file_and_more.py delete mode 100644 twitch/utils/__init__.py delete mode 100644 twitch/utils/images.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 011da56..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -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 -``` \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index c92a4ae..d64463e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,9 +8,12 @@ "ASGI", "collectstatic", "createsuperuser", + "dateparser", + "djlint", "docstrings", "dotenv", "Hellsén", + "httpx", "IGDB", "isort", "Joakim", @@ -22,6 +25,7 @@ "prefetcher", "psutil", "pydocstyle", + "pygments", "pyright", "pytest", "Ravendawn", diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f288702..0000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - 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. - - - Copyright (C) - - 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 . - -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: - - Copyright (C) - 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 -. - - 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 -. diff --git a/accounts/__init__.py b/accounts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/accounts/admin.py b/accounts/admin.py deleted file mode 100644 index 5b8b94b..0000000 --- a/accounts/admin.py +++ /dev/null @@ -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) diff --git a/accounts/apps.py b/accounts/apps.py deleted file mode 100644 index 58ab970..0000000 --- a/accounts/apps.py +++ /dev/null @@ -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" diff --git a/accounts/forms.py b/accounts/forms.py deleted file mode 100644 index 7cc112e..0000000 --- a/accounts/forms.py +++ /dev/null @@ -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 "" diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py deleted file mode 100644 index f720c0d..0000000 --- a/accounts/migrations/0001_initial.py +++ /dev/null @@ -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()), - ], - ), - ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/accounts/models.py b/accounts/models.py deleted file mode 100644 index 86dfa9a..0000000 --- a/accounts/models.py +++ /dev/null @@ -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" diff --git a/accounts/urls.py b/accounts/urls.py deleted file mode 100644 index 4b6b9f6..0000000 --- a/accounts/urls.py +++ /dev/null @@ -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"), -] diff --git a/accounts/views.py b/accounts/views.py deleted file mode 100644 index 94ab94d..0000000 --- a/accounts/views.py +++ /dev/null @@ -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, - }, - ) diff --git a/compose.yml b/compose.yml deleted file mode 100644 index 8bd5180..0000000 --- a/compose.yml +++ /dev/null @@ -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 \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 4ca263e..b035ade 100644 --- a/config/settings.py +++ b/config/settings.py @@ -41,7 +41,6 @@ def get_data_dir() -> Path: DATA_DIR: Path = get_data_dir() ADMINS: list[tuple[str, str]] = [("Joakim Hellsén", "tlovinator@gmail.com")] -AUTH_USER_MODEL = "accounts.User" BASE_DIR: Path = Path(__file__).resolve().parent.parent DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" ROOT_URLCONF = "config.urls" @@ -112,13 +111,8 @@ LOGGING: dict[str, Any] = { } INSTALLED_APPS: list[str] = [ - "django.contrib.admin", - "django.contrib.auth", "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", "django.contrib.staticfiles", - "accounts.apps.AccountsConfig", "twitch.apps.TwitchConfig", ] @@ -127,8 +121,6 @@ MIDDLEWARE: list[str] = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "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, "OPTIONS": { "context_processors": [ - "django.contrib.auth.context_processors.auth", - "django.template.context_processors.i18n", "django.template.context_processors.debug", "django.template.context_processors.request", - "django.contrib.messages.context_processors.messages", ], }, }, ] -# PostgreSQL configuration (preferred when env vars provided) -DATABASES: dict[str, dict[str, Any]] = { # pyright: ignore[reportInvalidTypeForm] +# https://blog.pecar.me/django-sqlite-benchmark +DATABASES: dict[str, dict[str, str | Path | dict[str, str]]] = { "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.getenv("POSTGRES_DB"), - "USER": os.getenv("POSTGRES_USER", "ttvdrops"), - "PASSWORD": os.getenv("POSTGRES_PASSWORD", "ttvdrops"), - "HOST": os.getenv("POSTGRES_HOST", "localhost"), - "PORT": os.getenv("POSTGRES_PORT", "5432"), - "CONN_MAX_AGE": 60, - } + "ENGINE": "django.db.backends.sqlite3", + "NAME": DATA_DIR / "ttvdrops.sqlite3", + "OPTIONS": { + "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 + "transaction_mode": "IMMEDIATE", + }, + }, } - -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 if not TESTING: DEBUG_TOOLBAR_CONFIG: dict[str, str] = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"} INSTALLED_APPS = [ # pyright: ignore[reportConstantRedefinition] - "django_watchfiles", *INSTALLED_APPS, "debug_toolbar", - "django_browser_reload", ] MIDDLEWARE = [ # pyright: ignore[reportConstantRedefinition] "debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE, - "django_browser_reload.middleware.BrowserReloadMiddleware", ] diff --git a/config/urls.py b/config/urls.py index df2b84d..e64a6b4 100644 --- a/config/urls.py +++ b/config/urls.py @@ -4,15 +4,12 @@ from typing import TYPE_CHECKING from django.conf import settings from django.conf.urls.static import static -from django.contrib import admin from django.urls import include, path if TYPE_CHECKING: from django.urls.resolvers import URLPattern, URLResolver 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")), ] @@ -23,7 +20,6 @@ if not settings.TESTING: urlpatterns = [ *urlpatterns, *debug_toolbar_urls(), - path("__reload__/", include("django_browser_reload.urls")), ] # Serve media in development diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 9a875a2..0000000 --- a/conftest.py +++ /dev/null @@ -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) diff --git a/pyproject.toml b/pyproject.toml index e20bdb1..b6ee99b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,29 +6,23 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "dateparser>=1.2.2", - "django-browser-reload>=1.18.0", "django-debug-toolbar>=5.2.0", - "django-stubs[compatible-mypy]>=5.2.2", - "django-watchfiles>=1.1.0", "django>=5.2.4", "djlint>=1.36.4", "json-repair>=0.50.0", - "orjson>=3.11.1", "platformdirs>=4.3.8", "python-dotenv>=1.1.1", - "psycopg[binary]>=3.2.3", "pygments>=2.19.2", - "django-auto-prefetch>=1.13.0", "httpx>=0.28.1", ] [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] DJANGO_SETTINGS_MODULE = "config.settings" python_files = ["test_*.py", "*_test.py"] -addopts = ["-n", "auto", "--reuse-db", "--no-migrations"] +addopts = ["--reuse-db", "--no-migrations"] [tool.pyright] exclude = ["**/migrations/**"] @@ -96,9 +90,3 @@ line-length = 160 [tool.djlint] profile = "django" ignore = "H021" - -[tool.mypy] -plugins = ["mypy_django_plugin.main"] - -[tool.django-stubs] -django_settings_module = "config.settings" diff --git a/templates/accounts/login.html b/templates/accounts/login.html deleted file mode 100644 index 624e581..0000000 --- a/templates/accounts/login.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "base.html" %} -{% block title %} - Login -{% endblock title %} -{% block content %} -

Login

- {% if form.errors %} -
    - {% for field, errors in form.errors.items %} - {% for error in errors %}
  • {{ error }}
  • {% endfor %} - {% endfor %} -
- {% endif %} -
- {% csrf_token %} - - - - - -
-

- Don't have an account? Sign up here -

- -{% endblock content %} diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html deleted file mode 100644 index 2eeba2a..0000000 --- a/templates/accounts/profile.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} -{% block title %} - {{ user.username }} -{% endblock title %} -{% block content %} -

{{ user.username }}

-

- Joined -

- - - - - - - - - - - - - -
- Date Joined: - {{ user.date_joined|date:"F d, Y" }}
- Last Login: - {{ user.last_login|date:"F d, Y H:i"|default:"Never" }}
- Email: - {{ user.email|default:"Not provided" }}
- Logout -

Will get notifications for these subscriptions:

-

Games

-
    - {% for item in games_with_inheritance %} -
  • - {{ item.game.display_name }} - {% if item.is_inherited %} - (inherited from {{ item.inherited_from }}) - {% endif %} -
  • - {% empty %} -
  • You have no game subscriptions yet.
  • - {% endfor %} -
-

Organizations

- -{% endblock content %} diff --git a/templates/accounts/signup.html b/templates/accounts/signup.html deleted file mode 100644 index 0661adf..0000000 --- a/templates/accounts/signup.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "base.html" %} -{% block title %} - Sign Up -{% endblock title %} -{% block content %} -

Sign Up

- {% if form.errors %} -
    - {% for field, errors in form.errors.items %} - {% for error in errors %}
  • {{ error }}
  • {% endfor %} - {% endfor %} -
- {% endif %} -
- {% csrf_token %} - - - {% if form.username.help_text %}{{ form.username.help_text }}{% endif %} - - - {% if form.password1.help_text %}{{ form.password1.help_text }}{% endif %} - - - {% if form.password2.help_text %}{{ form.password2.help_text }}{% endif %} - -
-

- Already have an account? Login here -

-{% endblock content %} diff --git a/templates/base.html b/templates/base.html index 5f8437a..7df7ddd 100644 --- a/templates/base.html +++ b/templates/base.html @@ -79,17 +79,7 @@ Organizations | Channels | RSS | - {% if user.is_authenticated %} - Debug | - {% if user.is_staff %} - Admin | - {% endif %} - {{ user.username }} - {% else %} - Login | - Sign Up - {% endif %} - | + Debug |
diff --git a/templates/twitch/campaign_detail.html b/templates/twitch/campaign_detail.html index dbfd1d2..f55b64f 100644 --- a/templates/twitch/campaign_detail.html +++ b/templates/twitch/campaign_detail.html @@ -7,7 +7,7 @@ {% if campaign.game %}

- {{ campaign.game.name }} - {{ campaign.clean_name }} + {{ campaign.game.get_game_name }} - {{ campaign.clean_name }}

{% else %}

{{ campaign.clean_name }}

@@ -29,19 +29,35 @@

{{ campaign.description|linebreaksbr }}

- + {% if campaign.end_at < now %} + + {% else %} + + {% endif %}
- + {% if campaign.start_at > now %} + + {% else %} + + {% endif %}
@@ -56,7 +72,7 @@
@@ -117,20 +133,13 @@ {% for benefit in drop.drop.benefits.all %} - {% if benefit.image_best_url or benefit.image_asset_url %} + {% if benefit.image_asset_url %} {{ benefit.name }} - {% else %} - No Image Available {% endif %} {% endfor %} diff --git a/templates/twitch/campaign_list.html b/templates/twitch/campaign_list.html index 3b25975..e46ec9f 100644 --- a/templates/twitch/campaign_list.html +++ b/templates/twitch/campaign_list.html @@ -53,8 +53,8 @@ style="margin-bottom: 3rem">
- {% if game_group.grouper.box_art_best_url %} - Box art for {{ game_group.grouper.display_name }}
- Image for {{ campaign.name }}({{ game.name }}){% endif %} - {% if game.box_art_best_url %} + {% if game.box_art %} {{ game.name }} {% endif %} diff --git a/templates/twitch/games_grid.html b/templates/twitch/games_grid.html index 36fde9b..2fb8224 100644 --- a/templates/twitch/games_grid.html +++ b/templates/twitch/games_grid.html @@ -22,8 +22,8 @@ flex: 1 1 160px; text-align: center">
- {% if item.game.box_art_best_url %} - Box art for {{ item.game.display_name }} None: """Add command arguments. @@ -81,6 +96,11 @@ class Command(BaseCommand): action="store_true", 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: """Execute the command. @@ -89,17 +109,40 @@ class Command(BaseCommand): **options: Arbitrary keyword arguments. Raises: - CommandError: If the file/directory doesn't exist, isn't a JSON file, - or has an invalid JSON structure. - ValueError: If the JSON file has an invalid structure. - TypeError: If the JSON file has an invalid JSON structure. - AttributeError: If the JSON file has an invalid JSON structure. - KeyError: If the JSON file has an invalid JSON structure. - IndexError: If the JSON file has an invalid JSON structure. + CommandError: If a critical error occurs and --continue-on-error is not set. + ValueError: If the input data is invalid. + TypeError: If the input data is of an unexpected type. + AttributeError: If expected attributes are missing in the data. + KeyError: If expected keys are missing in the data. + IndexError: If list indices are out of range in the data. + """ paths: list[str] = options["paths"] processed_dir: str = options["processed_dir"] 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: try: @@ -129,6 +172,20 @@ class Command(BaseCommand): self.stdout.write(self.style.WARNING("Interrupted by user, exiting import.")) 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: """Process drops from a file or directory. @@ -138,8 +195,7 @@ class Command(BaseCommand): processed_path: Name of subdirectory to move processed files to. Raises: - CommandError: If the file/directory doesn't exist, isn't a JSON file, - or has an invalid JSON structure. + CommandError: If the path is neither a file nor a directory. """ if path.is_file(): 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. Args: - directory: Path to the directory containing JSON files. - processed_path: Path to the subdirectory where processed files will be moved. - continue_on_error: Whether to continue processing remaining files if an error occurs. + directory: The directory containing JSON files. + processed_path: Name of subdirectory to move processed files to. + continue_on_error: Continue processing if an error occurs. Raises: - CommandError: If the path is invalid or moving files fails. - ValueError: If a JSON file has an invalid structure. - TypeError: If a JSON file has an invalid structure. - AttributeError: If a JSON file has an invalid structure. - KeyError: If a JSON file has an invalid structure. - IndexError: If a JSON file has an invalid structure. - KeyboardInterrupt: If processing is interrupted by the user. + AttributeError: If expected attributes are missing in the data. + CommandError: If a critical error occurs and --continue-on-error is not set. + IndexError: If list indices are out of range in the data. + KeyboardInterrupt: If the process is interrupted by the user. + KeyError: If expected keys are missing in the data. + TypeError: If the input data is of an unexpected type. + ValueError: If the input data is invalid. """ json_files: list[Path] = list(directory.glob("*.json")) if not json_files: @@ -190,51 +246,39 @@ class Command(BaseCommand): total_files: int = len(json_files) 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] = { 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] - self.stdout.write(f"Processing file {json_file.name}...") try: future.result() except CommandError as e: 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 self.stdout.write(self.style.ERROR(f"Error processing {json_file}: {e}")) except (ValueError, TypeError, AttributeError, KeyError, IndexError): 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 self.stdout.write(self.style.ERROR(f"Data error processing {json_file}")) 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}." - self.stdout.write(self.style.SUCCESS(msg)) + msg: str = f"Processed {total_files} JSON files in {directory}. Moved processed files to {processed_path}." + self.stdout.write(self.style.SUCCESS(msg)) - def update_processing_progress(self, total_files: int, start_time: float, processed: int) -> None: - """Update and display processing progress. - - Args: - total_files: Total number of files to process. - 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}") + except KeyboardInterrupt: + self.stdout.write(self.style.WARNING("Interruption received, shutting down threads immediately...")) + executor.shutdown(wait=False, cancel_futures=True) + # Re-raise the exception to allow the main `handle` method to catch it and exit + raise def _process_file(self, file_path: Path, processed_path: Path) -> None: """Process a single JSON file. @@ -276,7 +320,7 @@ class Command(BaseCommand): target_dir.mkdir(parents=True, exist_ok=True) 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 # Some responses have errors: @@ -286,7 +330,7 @@ class Command(BaseCommand): actual_error_dir: Path = processed_path / "actual_error" actual_error_dir.mkdir(parents=True, exist_ok=True) 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 # 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 ): 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 # 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: 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 # If file only contains {"data": {"game": {}}} remove the file @@ -319,7 +363,7 @@ class Command(BaseCommand): game_data = data["data"]["game"] if isinstance(game_data, dict) and game_data.get("__typename") == "Game": 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 # 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 ): 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 if isinstance(data, list): @@ -363,30 +407,28 @@ class Command(BaseCommand): shutil.move(str(file_path), str(processed_path)) except FileExistsError: # Rename the file if contents is different than the existing one - with ( - file_path.open("rb") as f1, - (processed_path / file_path.name).open("rb") as f2, - ): - if f1.read() != f2.read(): - new_name: Path = processed_path / f"{file_path.stem}_duplicate{file_path.suffix}" - shutil.move(str(file_path), str(new_name)) - self.stdout.write(f"Moved {file_path!s} to {new_name!s} (content differs)") - else: - self.stdout.write(f"{file_path!s} already exists in {processed_path!s}, removing original file.") - file_path.unlink() + try: + with ( + file_path.open("rb") as f1, + (processed_path / file_path.name).open("rb") as f2, + ): + if f1.read() != f2.read(): + new_name: Path = processed_path / f"{file_path.stem}_duplicate{file_path.suffix}" + shutil.move(str(file_path), str(new_name)) + tqdm.write(f"Moved {file_path!s} to {new_name!s} (content differs)") + else: + tqdm.write(f"{file_path!s} already exists in {processed_path!s}, removing original file.") + file_path.unlink() + except FileNotFoundError: + tqdm.write(f"{file_path!s} not found when handling duplicate case, skipping.") except FileNotFoundError: - self.stdout.write(f"{file_path!s} not found, skipping.") + tqdm.write(f"{file_path!s} not found, skipping.") except (PermissionError, OSError, shutil.Error) as e: self.stdout.write(self.style.ERROR(f"Error moving {file_path!s} to {processed_path!s}: {e}")) traceback.print_exc() def import_drop_campaign(self, data: dict[str, Any], file_path: Path) -> None: - """Find and import drop campaign data from various JSON structures. - - Args: - data: The JSON data. - file_path: The path to the file being processed. - """ + """Find and import drop campaign data from various JSON structures.""" # Add this check: If this is a known "empty" response, ignore it silently. if ( "data" in data @@ -403,7 +445,7 @@ class Command(BaseCommand): d: The dictionary to check for drop campaign data. Returns: - True if any drop campaign data was imported, False otherwise. + True if import was attempted, False otherwise. """ if not isinstance(d, dict): return False @@ -454,7 +496,7 @@ class Command(BaseCommand): self.import_to_db(data, file_path=file_path) 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: """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) organization: Organization | None = self.owner_update_or_create(campaign_data=campaign_data) - if organization: + if organization and game.owner != organization: game.owner = organization game.save(update_fields=["owner"]) @@ -476,14 +518,12 @@ class Command(BaseCommand): for drop_data in campaign_data.get("timeBasedDrops", []): 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: 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", []) 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) return @@ -499,30 +539,31 @@ class Command(BaseCommand): } # 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): benefit_defaults[key] = value.strip() # 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, _ = DropBenefit.objects.update_or_create( - id=benefit_data["id"], - defaults=benefit_defaults, - ) + # Use cached create/update for benefits + benefit = self._get_or_create_benefit(benefit_data["id"], benefit_defaults) - # Cache benefit image if available and not already cached - if (not benefit.image_file) and benefit.image_asset_url: - rel_path: str | None = cache_remote_image(benefit.image_asset_url, "benefits/images") - if rel_path: - benefit.image_file.name = rel_path - benefit.save(update_fields=["image_file"]) - - DropBenefitEdge.objects.update_or_create( - drop=time_based_drop, - benefit=benefit, - defaults={"entitlement_limit": benefit_edge.get("entitlementLimit", 1)}, - ) + try: + with transaction.atomic(): + drop_benefit_edge, created = DropBenefitEdge.objects.update_or_create( + drop=time_based_drop, + benefit=benefit, + 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: """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. - "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: TimeBasedDrop: The created or updated TimeBasedDrop instance. - """ time_based_drop_defaults: dict[str, Any] = { "campaign": drop_campaign, @@ -551,35 +594,182 @@ class Command(BaseCommand): } # 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): time_based_drop_defaults[key] = value.strip() # 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, created = TimeBasedDrop.objects.update_or_create(id=drop_data["id"], defaults=time_based_drop_defaults) - if created: - self.stdout.write(self.style.SUCCESS(f"Successfully imported time-based drop {time_based_drop.name} (ID: {time_based_drop.id})")) + try: + with transaction.atomic(): + time_based_drop, created = TimeBasedDrop.objects.update_or_create(id=drop_data["id"], defaults=time_based_drop_defaults) + if created: + 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 - def drop_campaign_update_or_get( + def _get_or_create_cached( self, - campaign_data: dict[str, Any], - game: Game, - ) -> DropCampaign: - """Update or create a drop campaign. + model_name: str, + model_class: type[Game | Organization | DropCampaign | Channel | DropBenefit], + obj_id: str | int, + 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: - campaign_data: The drop campaign data to import. - game: The game this drop campaign is for. - organization: The company that owns the game. If None, the campaign will not have an owner. + model_name: The name of the model (used for cache and lock). + model_class: The Django model class. + obj_id: The ID of the object to get or create. + defaults: A dictionary of fields to set on creation or update. 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_is_enabled = allow_data.get("isEnabled") @@ -595,18 +785,24 @@ class Command(BaseCommand): "is_account_connected": campaign_data.get("self", {}).get("isAccountConnected"), "allow_is_enabled": allow_is_enabled, } + # 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): drop_campaign_defaults[key] = value.strip() # 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, created = DropCampaign.objects.update_or_create( - id=campaign_data["id"], + drop_campaign = self._get_or_create_cached( + model_name="campaign", + model_class=DropCampaign, + obj_id=campaign_data["id"], 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) allow_channels: list[dict[str, str]] = allow_data.get("channels", []) @@ -625,87 +821,23 @@ class Command(BaseCommand): # Filter out None values channel_defaults = {k: v for k, v in channel_defaults.items() if v is not None} - channel, _ = Channel.objects.update_or_create( - id=channel_data["id"], + # Use cached helper for channels + channel = self._get_or_create_cached( + model_name="channel", + model_class=Channel, + obj_id=channel_data["id"], 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) - # Set the many-to-many relationship - drop_campaign.allow_channels.set(channel_objects) + # 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) - 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 - - 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 diff --git a/twitch/management/commands/update_search_vectors.py b/twitch/management/commands/update_search_vectors.py deleted file mode 100644 index ad71127..0000000 --- a/twitch/management/commands/update_search_vectors.py +++ /dev/null @@ -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.")) diff --git a/twitch/migrations/0001_initial.py b/twitch/migrations/0001_initial.py index 55a2a7a..47bf8c8 100644 --- a/twitch/migrations/0001_initial.py +++ b/twitch/migrations/0001_initial.py @@ -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 -from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): + """Initial migration. + + Args: + migrations (migrations.Migration): The base class for all migrations. + """ initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [] operations = [ migrations.CreateModel( - name='DropBenefit', + name="Game", fields=[ - ('id', models.TextField(primary_key=True, serialize=False)), - ('name', models.TextField(db_index=True)), - ('image_asset_url', models.URLField(blank=True, default='', max_length=500)), - ('created_at', models.DateTimeField(db_index=True)), - ('entitlement_limit', models.PositiveIntegerField(default=1)), - ('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='Game', - fields=[ - ('id', models.TextField(primary_key=True, serialize=False)), - ('slug', models.TextField(blank=True, db_index=True, default='')), - ('display_name', models.TextField(db_index=True)), - ('box_art', models.URLField(blank=True, default='', max_length=500)), + ("id", models.TextField(primary_key=True, serialize=False, verbose_name="Game ID")), + ( + "slug", + models.TextField( + blank=True, db_index=True, default="", help_text="Short unique identifier for the game.", max_length=200, verbose_name="Slug" + ), + ), + ("name", models.TextField(blank=True, db_index=True, default="", verbose_name="Name")), + ("display_name", models.TextField(blank=True, db_index=True, default="", verbose_name="Display name")), + ("box_art", models.URLField(blank=True, default="", max_length=500, verbose_name="Box art URL")), + ( + "box_art_file", + models.FileField(blank=True, help_text="Locally cached box art image served from this site.", null=True, upload_to="games/box_art/"), + ), + ("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={ - 'indexes': [models.Index(fields=['slug'], name='twitch_game_slug_a02d3c_idx'), models.Index(fields=['display_name'], name='twitch_game_display_a35ba3_idx')], - }, - ), - 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')], + "ordering": ["display_name"], }, ), migrations.CreateModel( - name='DropCampaign', + name="Channel", fields=[ - ('id', models.TextField(primary_key=True, serialize=False)), - ('name', models.TextField(db_index=True)), - ('description', models.TextField(blank=True)), - ('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( - model_name='dropbenefit', - name='owner_organization', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='drop_benefits', to='twitch.organization'), - ), - migrations.CreateModel( - name='TimeBasedDrop', - 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')), + ( + "id", + models.TextField(help_text="The unique Twitch identifier for the channel.", primary_key=True, serialize=False, verbose_name="Channel ID"), + ), + ("name", models.TextField(db_index=True, help_text="The lowercase username of the channel.", verbose_name="Username")), + ( + "display_name", + models.TextField(db_index=True, help_text="The display name of the channel (with proper capitalization).", 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={ - '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( - model_name='dropcampaign', - index=models.Index(fields=['name'], name='twitch_drop_name_3b70b3_idx'), + model_name="dropcampaign", + index=models.Index(fields=["name"], name="twitch_drop_name_3b70b3_idx"), ), migrations.AddIndex( - model_name='dropcampaign', - index=models.Index(fields=['start_at', 'end_at'], name='twitch_drop_start_a_6e5fb6_idx'), + model_name="dropcampaign", + index=models.Index(fields=["start_at", "end_at"], name="twitch_drop_start_a_6e5fb6_idx"), ), migrations.AddIndex( - model_name='dropcampaign', - index=models.Index(fields=['game'], name='twitch_drop_game_id_868e70_idx'), + 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.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( - model_name='dropcampaign', - index=models.Index(fields=['owner'], name='twitch_drop_owner_i_37241d_idx'), + model_name="game", + index=models.Index(fields=["display_name"], name="twitch_game_display_a35ba3_idx"), ), migrations.AddIndex( - model_name='dropbenefit', - index=models.Index(fields=['name'], name='twitch_drop_name_7125ff_idx'), + model_name="game", + index=models.Index(fields=["name"], name="twitch_game_name_c92c15_idx"), ), migrations.AddIndex( - model_name='dropbenefit', - index=models.Index(fields=['created_at'], name='twitch_drop_created_a3563e_idx'), + model_name="game", + index=models.Index(fields=["slug"], name="twitch_game_slug_a02d3c_idx"), ), migrations.AddIndex( - model_name='dropbenefit', - index=models.Index(fields=['distribution_type'], name='twitch_drop_distrib_08b224_idx'), + model_name="game", + index=models.Index(condition=models.Q(("owner__isnull", False)), fields=["owner"], name="game_owner_partial_idx"), ), migrations.AddIndex( - model_name='dropbenefit', - index=models.Index(fields=['game'], name='twitch_drop_game_id_a9209e_idx'), + model_name="timebaseddrop", + index=models.Index(fields=["name"], name="twitch_time_name_47c0f4_idx"), ), migrations.AddIndex( - model_name='dropbenefit', - index=models.Index(fields=['owner_organization'], name='twitch_drop_owner_o_45b4cc_idx'), + model_name="timebaseddrop", + index=models.Index(fields=["start_at", "end_at"], name="twitch_time_start_a_c481f1_idx"), ), migrations.AddIndex( - model_name='timebaseddrop', - index=models.Index(fields=['name'], name='twitch_time_name_47c0f4_idx'), + model_name="timebaseddrop", + index=models.Index(fields=["required_minutes_watched"], name="twitch_time_require_82c30c_idx"), ), migrations.AddIndex( - model_name='timebaseddrop', - index=models.Index(fields=['start_at', 'end_at'], name='twitch_time_start_a_c481f1_idx'), + model_name="timebaseddrop", + 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( - model_name='timebaseddrop', - index=models.Index(fields=['campaign'], name='twitch_time_campaig_bbe349_idx'), + model_name="dropbenefitedge", + 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( - model_name='timebaseddrop', - index=models.Index(fields=['required_minutes_watched'], name='twitch_time_require_82c30c_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')}, + model_name="twitchgamedata", + index=models.Index(fields=["name"], name="twitch_twit_name_5dda5f_idx"), ), ] diff --git a/twitch/migrations/0002_game_name_alter_game_display_name_and_more.py b/twitch/migrations/0002_game_name_alter_game_display_name_and_more.py deleted file mode 100644 index a4075e7..0000000 --- a/twitch/migrations/0002_game_name_alter_game_display_name_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/twitch/migrations/0003_alter_dropbenefit_created_at_and_more.py b/twitch/migrations/0003_alter_dropbenefit_created_at_and_more.py deleted file mode 100644 index 8aed1ac..0000000 --- a/twitch/migrations/0003_alter_dropbenefit_created_at_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/twitch/migrations/0004_alter_dropcampaign_end_at_and_more.py b/twitch/migrations/0004_alter_dropcampaign_end_at_and_more.py deleted file mode 100644 index ee944da..0000000 --- a/twitch/migrations/0004_alter_dropcampaign_end_at_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/twitch/migrations/0005_alter_timebaseddrop_end_at_and_more.py b/twitch/migrations/0005_alter_timebaseddrop_end_at_and_more.py deleted file mode 100644 index a9e8e0f..0000000 --- a/twitch/migrations/0005_alter_timebaseddrop_end_at_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/twitch/migrations/0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more.py b/twitch/migrations/0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more.py deleted file mode 100644 index 3e4e9da..0000000 --- a/twitch/migrations/0006_alter_dropbenefit_options_alter_dropcampaign_options_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/twitch/migrations/0007_remove_game_unique_game_slug.py b/twitch/migrations/0007_remove_game_unique_game_slug.py deleted file mode 100644 index 14ca989..0000000 --- a/twitch/migrations/0007_remove_game_unique_game_slug.py +++ /dev/null @@ -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', - ), - ] diff --git a/twitch/migrations/0008_rename_created_at_dropcampaign_added_at_and_more.py b/twitch/migrations/0008_rename_created_at_dropcampaign_added_at_and_more.py deleted file mode 100644 index 76dffaa..0000000 --- a/twitch/migrations/0008_rename_created_at_dropcampaign_added_at_and_more.py +++ /dev/null @@ -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.'), - ), - ] diff --git a/twitch/migrations/0009_postgresql_optimizations_fixed.py b/twitch/migrations/0009_postgresql_optimizations_fixed.py deleted file mode 100644 index 26e4b98..0000000 --- a/twitch/migrations/0009_postgresql_optimizations_fixed.py +++ /dev/null @@ -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'), - ), - ] diff --git a/twitch/migrations/0010_alter_dropbenefit_id_alter_game_id_and_more.py b/twitch/migrations/0010_alter_dropbenefit_id_alter_game_id_and_more.py deleted file mode 100644 index 58f33eb..0000000 --- a/twitch/migrations/0010_alter_dropbenefit_id_alter_game_id_and_more.py +++ /dev/null @@ -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), - ), - ] diff --git a/twitch/migrations/0011_remove_timebaseddrop_drop_positive_minutes_and_more.py b/twitch/migrations/0011_remove_timebaseddrop_drop_positive_minutes_and_more.py deleted file mode 100644 index ff323d4..0000000 --- a/twitch/migrations/0011_remove_timebaseddrop_drop_positive_minutes_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/twitch/migrations/0012_dropbenefit_search_vector_game_search_vector_and_more.py b/twitch/migrations/0012_dropbenefit_search_vector_game_search_vector_and_more.py deleted file mode 100644 index f7c7fc8..0000000 --- a/twitch/migrations/0012_dropbenefit_search_vector_game_search_vector_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py b/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py deleted file mode 100644 index aeb5d59..0000000 --- a/twitch/migrations/0013_dropcampaign_allow_is_enabled_channel_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/twitch/migrations/0014_alter_channel_options_alter_dropbenefit_options_and_more.py b/twitch/migrations/0014_alter_channel_options_alter_dropbenefit_options_and_more.py deleted file mode 100644 index a7757d1..0000000 --- a/twitch/migrations/0014_alter_channel_options_alter_dropbenefit_options_and_more.py +++ /dev/null @@ -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()), - ], - ), - ] diff --git a/twitch/migrations/0015_alter_dropbenefitedge_benefit_and_more.py b/twitch/migrations/0015_alter_dropbenefitedge_benefit_and_more.py deleted file mode 100644 index 3b25f0f..0000000 --- a/twitch/migrations/0015_alter_dropbenefitedge_benefit_and_more.py +++ /dev/null @@ -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'), - ), - ] diff --git a/twitch/migrations/0016_add_local_image_fields.py b/twitch/migrations/0016_add_local_image_fields.py deleted file mode 100644 index a596af9..0000000 --- a/twitch/migrations/0016_add_local_image_fields.py +++ /dev/null @@ -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/"), - ), - ] diff --git a/twitch/migrations/0017_alter_dropbenefit_image_file_and_more.py b/twitch/migrations/0017_alter_dropbenefit_image_file_and_more.py deleted file mode 100644 index 7981713..0000000 --- a/twitch/migrations/0017_alter_dropbenefit_image_file_and_more.py +++ /dev/null @@ -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/'), - ), - ] diff --git a/twitch/models.py b/twitch/models.py index 6564692..be6217c 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -1,18 +1,11 @@ from __future__ import annotations import logging -import re 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.utils import timezone -from accounts.models import User - if TYPE_CHECKING: import datetime @@ -20,17 +13,15 @@ logger: logging.Logger = logging.getLogger("ttvdrops") # MARK: Organization -class Organization(auto_prefetch.Model): +class Organization(models.Model): """Represents an organization on Twitch that can own drop campaigns.""" - id = models.CharField( - max_length=255, + id = models.TextField( primary_key=True, verbose_name="Organization ID", help_text="The unique Twitch identifier for the organization.", ) - name = models.CharField( - max_length=255, + name = models.TextField( db_index=True, unique=True, verbose_name="Name", @@ -47,16 +38,10 @@ class Organization(auto_prefetch.Model): help_text="Timestamp when this organization record was last updated.", ) - # PostgreSQL full-text search field - search_vector = SearchVectorField(null=True, blank=True) - - class Meta(auto_prefetch.Model.Meta): + class Meta: ordering = ["name"] indexes: ClassVar[list] = [ - # Regular B-tree index for name lookups 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: @@ -65,11 +50,11 @@ class Organization(auto_prefetch.Model): # MARK: Game -class Game(auto_prefetch.Model): +class Game(models.Model): """Represents a game on Twitch.""" - id = models.CharField(max_length=255, primary_key=True, verbose_name="Game ID") - slug = models.CharField( + id = models.TextField(primary_key=True, verbose_name="Game ID") + slug = models.TextField( max_length=200, blank=True, default="", @@ -77,15 +62,13 @@ class Game(auto_prefetch.Model): verbose_name="Slug", help_text="Short unique identifier for the game.", ) - name = models.CharField( - max_length=255, + name = models.TextField( blank=True, default="", db_index=True, verbose_name="Name", ) - display_name = models.CharField( - max_length=255, + display_name = models.TextField( blank=True, default="", db_index=True, @@ -97,7 +80,7 @@ class Game(auto_prefetch.Model): default="", verbose_name="Box art URL", ) - # Locally cached image file for the game's box art + box_art_file = models.FileField( upload_to="games/box_art/", blank=True, @@ -105,10 +88,7 @@ class Game(auto_prefetch.Model): help_text="Locally cached box art image served from this site.", ) - # PostgreSQL full-text search field - search_vector = SearchVectorField(null=True, blank=True) - - owner = auto_prefetch.ForeignKey( + owner = models.ForeignKey( Organization, on_delete=models.SET_NULL, related_name="games", @@ -128,16 +108,12 @@ class Game(auto_prefetch.Model): help_text="Timestamp when this game record was last updated.", ) - class Meta(auto_prefetch.Model.Meta): + class Meta: ordering = ["display_name"] indexes: ClassVar[list] = [ - models.Index(fields=["slug"]), - # Regular B-tree indexes for name lookups models.Index(fields=["display_name"]), models.Index(fields=["name"]), - # Full-text search index (GIN works with SearchVectorField) - GinIndex(fields=["search_vector"], name="game_search_vector_idx"), - # Partial index for games with owners only + models.Index(fields=["slug"]), 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 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 def get_game_name(self) -> str: """Return the best available name for the game.""" @@ -207,7 +153,7 @@ class Game(auto_prefetch.Model): # MARK: TwitchGame -class TwitchGameData(auto_prefetch.Model): +class TwitchGameData(models.Model): """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. @@ -220,8 +166,8 @@ class TwitchGameData(auto_prefetch.Model): igdb_id: Optional IGDB id for the game """ - id = models.CharField(max_length=255, primary_key=True, verbose_name="Twitch Game ID") - game = auto_prefetch.ForeignKey( + id = models.TextField(primary_key=True, verbose_name="Twitch Game ID") + game = models.ForeignKey( Game, on_delete=models.SET_NULL, 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.", ) - 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( max_length=500, blank=True, @@ -239,39 +185,36 @@ class TwitchGameData(auto_prefetch.Model): verbose_name="Box art URL", 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.") updated_at = models.DateTimeField(auto_now=True, help_text="Record last update time.") - class Meta(auto_prefetch.Model.Meta): + class Meta: ordering = ["name"] indexes: ClassVar[list] = [ models.Index(fields=["name"]), ] - def __str__(self) -> str: # pragma: no cover - trivial + def __str__(self) -> str: return self.name or self.id # MARK: Channel -class Channel(auto_prefetch.Model): +class Channel(models.Model): """Represents a Twitch channel that can participate in drop campaigns.""" - id = models.CharField( - max_length=255, + id = models.TextField( primary_key=True, verbose_name="Channel ID", help_text="The unique Twitch identifier for the channel.", ) - name = models.CharField( - max_length=255, + name = models.TextField( db_index=True, verbose_name="Username", help_text="The lowercase username of the channel.", ) - display_name = models.CharField( - max_length=255, + display_name = models.TextField( db_index=True, verbose_name="Display Name", 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.", ) - class Meta(auto_prefetch.Model.Meta): + class Meta: ordering = ["display_name"] indexes: ClassVar[list] = [ models.Index(fields=["name"]), @@ -300,16 +243,14 @@ class Channel(auto_prefetch.Model): # MARK: DropCampaign -class DropCampaign(auto_prefetch.Model): +class DropCampaign(models.Model): """Represents a Twitch drop campaign.""" - id = models.CharField( - max_length=255, + id = models.TextField( primary_key=True, help_text="Unique Twitch identifier for the campaign.", ) - name = models.CharField( - max_length=255, + name = models.TextField( db_index=True, help_text="Name of the drop campaign.", ) @@ -335,7 +276,6 @@ class DropCampaign(auto_prefetch.Model): default="", help_text="URL to an image representing the campaign.", ) - # Locally cached campaign image image_file = models.FileField( upload_to="campaigns/images/", blank=True, @@ -369,10 +309,7 @@ class DropCampaign(auto_prefetch.Model): help_text="Channels that are allowed to participate in this campaign.", ) - # PostgreSQL full-text search field - search_vector = SearchVectorField(null=True, blank=True) - - game = auto_prefetch.ForeignKey( + game = models.ForeignKey( Game, on_delete=models.CASCADE, related_name="drop_campaigns", @@ -390,7 +327,7 @@ class DropCampaign(auto_prefetch.Model): help_text="Timestamp when this campaign record was last updated.", ) - class Meta(auto_prefetch.Model.Meta): + class Meta: ordering = ["-start_at"] constraints = [ # Ensure end_at is after start_at when both are set @@ -400,13 +337,8 @@ class DropCampaign(auto_prefetch.Model): ), ] indexes: ClassVar[list] = [ - # Regular B-tree index for campaign name lookups 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"]), - # 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"), ] @@ -462,16 +394,14 @@ class DropCampaign(auto_prefetch.Model): # MARK: DropBenefit -class DropBenefit(auto_prefetch.Model): +class DropBenefit(models.Model): """Represents a benefit that can be earned from a drop.""" - id = models.CharField( - max_length=255, + id = models.TextField( primary_key=True, help_text="Unique Twitch identifier for the benefit.", ) - name = models.CharField( - max_length=255, + name = models.TextField( db_index=True, blank=True, default="N/A", @@ -483,7 +413,6 @@ class DropBenefit(auto_prefetch.Model): default="", help_text="URL to the benefit's image asset.", ) - # Locally cached benefit image image_file = models.FileField( upload_to="benefits/images/", blank=True, @@ -505,7 +434,7 @@ class DropBenefit(auto_prefetch.Model): default=False, help_text="Whether the benefit is available on iOS.", ) - distribution_type = models.CharField( + distribution_type = models.TextField( max_length=50, db_index=True, blank=True, @@ -513,9 +442,6 @@ class DropBenefit(auto_prefetch.Model): help_text="Type of distribution for this benefit.", ) - # PostgreSQL full-text search field - search_vector = SearchVectorField(null=True, blank=True) - added_at = models.DateTimeField( auto_now_add=True, db_index=True, @@ -526,16 +452,12 @@ class DropBenefit(auto_prefetch.Model): help_text="Timestamp when this benefit record was last updated.", ) - class Meta(auto_prefetch.Model.Meta): + class Meta: ordering = ["-created_at"] indexes: ClassVar[list] = [ - # Regular B-tree index for benefit name lookups 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=["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"), ] @@ -543,28 +465,16 @@ class DropBenefit(auto_prefetch.Model): """Return a string representation of the drop benefit.""" 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 -class TimeBasedDrop(auto_prefetch.Model): +class TimeBasedDrop(models.Model): """Represents a time-based drop in a drop campaign.""" - id = models.CharField( - max_length=255, + id = models.TextField( primary_key=True, help_text="Unique Twitch identifier for the time-based drop.", ) - name = models.CharField( - max_length=255, + name = models.TextField( db_index=True, help_text="Name of the time-based drop.", ) @@ -591,11 +501,8 @@ class TimeBasedDrop(auto_prefetch.Model): help_text="Datetime when this drop expires.", ) - # PostgreSQL full-text search field - search_vector = SearchVectorField(null=True, blank=True) - # Foreign keys - campaign = auto_prefetch.ForeignKey( + campaign = models.ForeignKey( DropCampaign, on_delete=models.CASCADE, 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.", ) - class Meta(auto_prefetch.Model.Meta): + class Meta: ordering = ["start_at"] constraints = [ # Ensure end_at is after start_at when both are set @@ -632,13 +539,9 @@ class TimeBasedDrop(auto_prefetch.Model): ), ] indexes: ClassVar[list] = [ - # Regular B-tree index for drop name lookups 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=["required_minutes_watched"]), - # Covering index for common queries (includes campaign_id from FK) models.Index(fields=["campaign", "start_at", "required_minutes_watched"]), ] @@ -648,15 +551,15 @@ class TimeBasedDrop(auto_prefetch.Model): # MARK: DropBenefitEdge -class DropBenefitEdge(auto_prefetch.Model): +class DropBenefitEdge(models.Model): """Represents the relationship between a TimeBasedDrop and a DropBenefit.""" - drop = auto_prefetch.ForeignKey( + drop = models.ForeignKey( TimeBasedDrop, on_delete=models.CASCADE, help_text="The time-based drop in this relationship.", ) - benefit = auto_prefetch.ForeignKey( + benefit = models.ForeignKey( DropBenefit, on_delete=models.CASCADE, 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.", ) - class Meta(auto_prefetch.Model.Meta): + class Meta: constraints = [ models.UniqueConstraint(fields=("drop", "benefit"), name="unique_drop_benefit"), ] @@ -687,31 +590,3 @@ class DropBenefitEdge(auto_prefetch.Model): def __str__(self) -> str: """Return a string representation of the drop benefit edge.""" 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" diff --git a/twitch/urls.py b/twitch/urls.py index ff88a9d..bdc855b 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -25,10 +25,8 @@ urlpatterns: list[URLPattern] = [ path("games/", views.GamesGridView.as_view(), name="game_list"), path("games/list/", views.GamesListView.as_view(), name="game_list_simple"), path("games//", views.GameDetailView.as_view(), name="game_detail"), - path("games//subscribe/", views.subscribe_game_notifications, name="subscribe_notifications"), path("organizations/", views.OrgListView.as_view(), name="org_list"), path("organizations//", views.OrgDetailView.as_view(), name="organization_detail"), - path("organizations//subscribe/", views.subscribe_org_notifications, name="subscribe_org_notifications"), path("channels/", views.ChannelListView.as_view(), name="channel_list"), path("channels//", views.ChannelDetailView.as_view(), name="channel_detail"), path("rss/organizations/", OrganizationFeed(), name="organization_feed"), diff --git a/twitch/utils/__init__.py b/twitch/utils/__init__.py deleted file mode 100644 index efaab6d..0000000 --- a/twitch/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -# Utility package for twitch app diff --git a/twitch/utils/images.py b/twitch/utils/images.py deleted file mode 100644 index 8df1204..0000000 --- a/twitch/utils/images.py +++ /dev/null @@ -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 diff --git a/twitch/views.py b/twitch/views.py index 4a99f65..264f778 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -6,28 +6,24 @@ import logging from collections import OrderedDict, defaultdict 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.core.serializers import serialize from django.db.models import Count, F, Prefetch, Q from django.db.models.functions import Trim from django.db.models.query import QuerySet from django.http import HttpRequest, HttpResponse -from django.http.response import HttpResponseRedirect -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import render from django.utils import timezone from django.views.generic import DetailView, ListView from pygments import highlight from pygments.formatters import HtmlFormatter 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: from django.db.models import QuerySet from django.http import HttpRequest, HttpResponse - from django.http.response import HttpResponseRedirect logger: logging.Logger = logging.getLogger(__name__) @@ -125,12 +121,6 @@ class OrgDetailView(DetailView): context = super().get_context_data(**kwargs) 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] serialized_org = serialize( @@ -152,7 +142,6 @@ class OrgDetailView(DetailView): pretty_org_data = json.dumps(org_data[0], indent=4) context.update({ - "subscription": subscription, "games": games, "org_data": pretty_org_data, }) @@ -439,12 +428,6 @@ class GameDetailView(DetailView): context: dict[str, Any] = super().get_context_data(**kwargs) 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() all_campaigns: QuerySet[DropCampaign, DropCampaign] = ( DropCampaign.objects.filter(game=game) @@ -514,7 +497,6 @@ class GameDetailView(DetailView): "active_campaigns": active_campaigns, "upcoming_campaigns": upcoming_campaigns, "expired_campaigns": expired_campaigns, - "subscription": subscription, "owner": game.owner, "now": now, "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"]: campaigns_by_org_game[org_id]["games"][game_id] = { "name": game_name, - "box_art": campaign.game.box_art_best_url, + "box_art": campaign.game.box_art, "campaigns": [], } @@ -583,7 +565,6 @@ def dashboard(request: HttpRequest) -> HttpResponse: # MARK: /debug/ -@login_required def debug_view(request: HttpRequest) -> HttpResponse: """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) -# MARK: /games//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//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/ class GamesListView(GamesGridView): """List view for games in simple list format."""