Compare commits

..

159 Commits

Author SHA1 Message Date
pixeebot[bot]
c063c082ae Sandbox URL Creation 2025-07-16 03:18:05 +00:00
pacnpal
7815de158e feat: Complete Company Migration Project and Fix Autocomplete Issues
- Implemented a comprehensive migration from a single Company model to specialized entities (Operators, PropertyOwners, Manufacturers, Designers).
- Resolved critical issues in search suggestions that were returning 404 errors by fixing database queries and reordering URL patterns.
- Conducted extensive testing and validation of the new entity relationships, ensuring all core functionality is operational.
- Updated test suite to reflect changes in entity structure, including renaming fields from `owner` to `operator`.
- Addressed display issues in the user interface related to operator and manufacturer information.
- Completed migration cleanup, fixing references to the removed `companies` app across migration files and test configurations.
- Established a stable testing environment with successful test database creation and functional test infrastructure.
2025-07-05 22:00:21 -04:00
pacnpal
b871a1d396 fix: resolve broken migration dependencies and references after company app removal
- Updated migration files to remove references to the old `companies` app and replace them with new app dependencies (`operators` and `manufacturers`).
- Fixed foreign key references in migration files to point to the correct models in the new apps.
- Updated import statements in management commands and test files to reflect the new app structure.
- Completed a thorough validation of the migration system to ensure full functionality and operational status.
2025-07-05 09:55:36 -04:00
pacnpal
751cd86a31 Add operators and property owners functionality
- Implemented OperatorListView and OperatorDetailView for managing operators.
- Created corresponding templates for operator listing and detail views.
- Added PropertyOwnerListView and PropertyOwnerDetailView for managing property owners.
- Developed templates for property owner listing and detail views.
- Established relationships between parks and operators, and parks and property owners in the models.
- Created migrations to reflect the new relationships and fields in the database.
- Added admin interfaces for PropertyOwner management.
- Implemented tests for operators and property owners.
2025-07-04 14:49:36 -04:00
pacnpal
8360f3fd43 chore: Update README.md for accurate development environment setup and configuration guidance 2025-07-02 18:40:06 -04:00
pacnpal
b570cb6848 Implement comprehensive card layout improvements and testing
- Added operator/owner priority card implementation to enhance visibility on smaller screens.
- Completed adaptive grid system to eliminate white space issues and improve responsiveness across all card layouts.
- Verified card layout fixes through extensive testing, confirming balanced layouts across various screen sizes and content scenarios.
- Conducted investigation into layout inconsistencies, identifying critical issues and recommending immediate fixes.
- Assessed white space issues and confirmed no critical problems in current implementations.
- Documented comprehensive testing plan and results, ensuring all layouts are functioning as intended.
2025-07-02 16:37:23 -04:00
pacnpal
94736acdd5 chore: Remove completed OAuth configuration fix documentation 2025-06-27 21:32:33 -04:00
pacnpal
6781fa3564 feat: Comprehensive design assessments and optimizations for ThrillWiki
- Added critical design consistency assessment report highlighting major issues across various pages, including excessive white space and inconsistent element designs.
- Created detailed design assessment for park, ride, and company detail pages, identifying severe space utilization problems and poor information density.
- Documented successful layout optimization demonstration, showcasing improvements in visual design and user experience.
- Completed OAuth authentication testing for Google and Discord, confirming full functionality and readiness for production use.
- Conducted a thorough visual design examination report, identifying specific design flaws and inconsistencies, with recommendations for standardization and improvement.
2025-06-27 21:29:12 -04:00
pacnpal
4b11ec112e Refactor authentication system documentation: complete repair and verification reports, and analyze login form issues 2025-06-26 09:31:21 -04:00
pacnpal
de05a5abda Add comprehensive audit reports, design assessment, and non-authenticated features testing for ThrillWiki application
- Created critical functionality audit report identifying 7 critical issues affecting production readiness.
- Added design assessment report highlighting exceptional design quality and minor cosmetic fixes needed.
- Documented non-authenticated features testing results confirming successful functionality and public access.
- Implemented ride search form with autocomplete functionality and corresponding templates for search results.
- Developed tests for ride autocomplete functionality, ensuring proper filtering and authentication checks.
2025-06-25 20:30:02 -04:00
pacnpal
401449201c Fix search form duplication by updating event handler to submit the correct filter form and return JSON responses for park suggestions 2025-02-23 12:05:26 -05:00
pacnpal
1ca1362fee Implement park search suggestions with HTMX integration: replace legacy redirect with real-time suggestions and enhance UI for better user experience 2025-02-23 10:50:25 -05:00
pacnpal
02e4b82beb Allow unauthenticated access for autocomplete functionality 2025-02-22 15:17:49 -05:00
pacnpal
4339c5c5e0 Add autocomplete functionality for parks: implement BaseAutocomplete class and integrate with forms 2025-02-22 13:36:24 -05:00
pacnpal
5278ad39d0 Refactor imports and improve code organization: streamline import statements and enhance readability in parks/views.py 2025-02-21 20:37:03 -05:00
pacnpal
4d145ebabe Implement park search suggestions: add autocomplete functionality and improve search input handling 2025-02-21 20:36:12 -05:00
pacnpal
e4959b7a04 Improve address formatting in location widget: enhance address display logic and ensure fallback for missing fields 2025-02-21 20:20:00 -05:00
pacnpal
ef2437b7f4 2025-02-21 19:14:26 -05:00
pacnpal
3523274cbd Refactor error message handling: centralize required fields error message and improve park list template accessibility 2025-02-21 18:55:41 -05:00
pacnpal
d7951756dc Enhance park search functionality: update view mode handling and improve park list item layout 2025-02-21 18:52:01 -05:00
pacnpal
518fcbee22 Add custom development modes and guidelines for ThrillWiki project 2025-02-21 18:28:36 -05:00
pacnpal
41f1738cc1 Add migrations to alter primary key fields to BigAutoField for multiple models 2025-02-21 12:55:21 -05:00
pacnpal
645a74a4c3 Implement search functionality improvements: optimize database queries, enhance service layer, and update frontend interactions 2025-02-21 10:31:49 -05:00
pacnpal
8c85b2afd4 Update .clinerules: add guidelines for using UV with Django management commands 2025-02-19 11:13:21 -05:00
pacnpal
063398d220 Refactor development server startup instructions for clarity and conciseness 2025-02-19 09:59:39 -05:00
pacnpal
20ae4862e4 Add development server and package management guidelines to documentation 2025-02-19 09:56:23 -05:00
pacnpal
5541a5f02d Refactor park queryset logic: move base queryset to a dedicated module for improved organization and maintainability 2025-02-19 09:30:17 -05:00
pacnpal
78f465b273 Analyze feasibility of migrating from Django to Laravel; recommend maintaining current implementation due to high risks and costs 2025-02-18 10:43:13 -05:00
pacnpal
0b51ee123a Add comprehensive system architecture and feature documentation for ThrillWiki 2025-02-18 10:08:46 -05:00
pacnpal
c19aaf2f4b Implement ride count fields with real-time annotations; update filters and templates for consistency and accuracy 2025-02-13 16:44:30 -05:00
pacnpal
9d6f6dab2c Refactor park listing templates: implement grid and list view modes, enhance search results rendering, and improve error handling in search functionality 2025-02-13 12:19:23 -05:00
pacnpal
bba707fa98 Refactor search functionality: remove obsolete JavaScript and HTML templates; enhance error handling and response rendering for park search results 2025-02-13 10:29:57 -05:00
pacnpal
c197051b25 Enhance search functionality with loading indicators, dark mode support, and improved UI; implement event handling for search results and refine park filter tests for better coverage 2025-02-13 09:42:58 -05:00
pacnpal
1fe299fb4b Integrate parks app with site-wide search system; add filter configuration, error handling, and search interfaces 2025-02-12 16:59:20 -05:00
pacnpal
af57592496 Add search app configuration, views, and templates for advanced filtering functionality 2025-02-12 12:58:04 -05:00
pacnpal
62723d0e33 Implement site-wide search system architecture with modular design and enhanced backend components; integrate django-filter for improved filtering capabilities and security considerations 2025-02-12 12:41:35 -05:00
pacnpal
f5c063b76f Refactor ride list and search suggestion templates for improved structure and styling; update view logic to support dynamic template rendering based on request type 2025-02-11 15:24:14 -05:00
pacnpal
59efc39143 Add search suggestions feature with category filtering and display; enhance ride list view with improved search functionality 2025-02-11 13:41:05 -05:00
pacnpal
2e0d32819a Add views for ride management including detail, creation, and listing; implement search functionality for manufacturers, designers, and ride models 2025-02-11 10:41:40 -05:00
pacnpal
6034227796 Implement RideUpdateView and refactor search suggestion handling for improved modularity and error management 2025-02-10 23:35:58 -05:00
pacnpal
fb6c6ec37b Refactor cleanup logic in search script for improved readability and maintainability 2025-02-10 23:30:34 -05:00
pacnpal
99b935da19 Enhance search suggestions with request tracking and cleanup handlers 2025-02-10 22:10:11 -05:00
pacnpal
2756079010 Add search suggestions feature with improved filtering and UI 2025-02-10 21:27:59 -05:00
pacnpal
5195c234c6 Merge pull request #27 from pacnpal/dependabot/pip/whitenoise-6.9.0
[DEPENDABOT] Update: Bump whitenoise from 6.8.2 to 6.9.0
2025-02-10 17:29:22 -05:00
pacnpal
c861d4f6ae Merge branch 'main' into dependabot/pip/whitenoise-6.9.0 2025-02-10 17:19:54 -05:00
pacnpal
ac71e5f047 Merge pull request #28 from pacnpal/dependabot/pip/django-htmx-1.22.0
[DEPENDABOT] Update: Bump django-htmx from 1.21.0 to 1.22.0
2025-02-10 17:19:15 -05:00
pacnpal
bdbb864cef Merge pull request #29 from pacnpal/dependabot/pip/django-allauth-65.4.1
[DEPENDABOT] Update: Bump django-allauth from 65.4.0 to 65.4.1
2025-02-10 17:19:04 -05:00
pacnpal
b46e13426a Merge pull request #26 from pacnpal/dependabot/github_actions/actions/setup-python-5
[DEPENDABOT] Update Actions: Bump actions/setup-python from 4 to 5
2025-02-10 17:18:53 -05:00
pacnpal
39c8fe2c57 Merge pull request #30 from pacnpal/dependabot/pip/django-tailwind-cli-4.0.1
[DEPENDABOT] Update: Bump django-tailwind-cli from 2.21.1 to 4.0.1
2025-02-10 17:18:27 -05:00
pacnpal
c27f320a49 Merge pull request #31 from pacnpal/dependabot/pip/pytest-django-4.10.0
[DEPENDABOT] Update: Bump pytest-django from 4.9.0 to 4.10.0
2025-02-10 17:16:37 -05:00
pacnpal
f4c6cd99db Merge pull request #32 from pacnpal/dependabot/pip/django-pghistory-3.5.2
[DEPENDABOT] Update: Bump django-pghistory from 2.9.0 to 3.5.2
2025-02-10 17:16:13 -05:00
pacnpal
467f7ba3f8 Merge pull request #37 from pacnpal/pacnpal-patch-3
Update README.md
2025-02-10 17:11:34 -05:00
pacnpal
d4a1f88644 Update README.md 2025-02-10 17:10:13 -05:00
pacnpal
369c5e698e Merge pull request #35 from pacnpal/pacnpal-patch-2
Create README.md
2025-02-10 16:31:52 -05:00
pacnpal
19b7aee707 Create README.md 2025-02-10 16:20:31 -05:00
pacnpal
a09fd66d70 Merge pull request #33 from pacnpal/dependabot/pip/django-cors-headers-4.7.0
[DEPENDABOT] Update: Bump django-cors-headers from 4.6.0 to 4.7.0
2025-02-10 16:12:25 -05:00
pacnpal
910762722e Update review.yml 2025-02-10 15:03:59 -05:00
pacnpal
79e34473a4 Update review.yml 2025-02-10 15:02:47 -05:00
pacnpal
872f3378a1 Update review.yml 2025-02-10 14:59:00 -05:00
pacnpal
df91eb97b8 Add ParkContextRequired mixin to enforce park context in ride views; update URLs and templates for global ride listing 2025-02-10 14:48:29 -05:00
pacnpal
ad33332506 Update review.yml 2025-02-10 13:37:13 -05:00
pacnpal
69cdb0a554 Update review.yml 2025-02-10 13:36:58 -05:00
pacnpal
d2b6b712bf Update review.yml 2025-02-10 13:36:34 -05:00
pacnpal
1784644a52 Update review.yml 2025-02-10 13:36:19 -05:00
pacnpal
3c40a32925 Update review.yml 2025-02-10 13:36:06 -05:00
pacnpal
29392f0de1 Update review.yml 2025-02-10 13:35:29 -05:00
pacnpal
d0bd0e1bf9 Update review.yml 2025-02-10 13:35:03 -05:00
pacnpal
11e643a47a Update review.yml 2025-02-10 13:32:21 -05:00
pacnpal
db78de4cfe Update review.yml 2025-02-10 13:31:16 -05:00
pacnpal
4a495182bd Update review.yml 2025-02-10 13:28:58 -05:00
pacnpal
2add4c7fc2 Add links to ride names in ride list template for improved navigation 2025-02-10 12:45:45 -05:00
dependabot[bot]
f1c37f2bc1 [DEPENDABOT] Update: Bump django-cors-headers from 4.6.0 to 4.7.0
Bumps [django-cors-headers](https://github.com/adamchainz/django-cors-headers) from 4.6.0 to 4.7.0.
- [Changelog](https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/adamchainz/django-cors-headers/compare/4.6.0...4.7.0)

---
updated-dependencies:
- dependency-name: django-cors-headers
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 16:50:51 +00:00
dependabot[bot]
1c71ad9b6b [DEPENDABOT] Update: Bump django-pghistory from 2.9.0 to 3.5.2
Bumps [django-pghistory](https://github.com/AmbitionEng/django-pghistory) from 2.9.0 to 3.5.2.
- [Release notes](https://github.com/AmbitionEng/django-pghistory/releases)
- [Changelog](https://github.com/AmbitionEng/django-pghistory/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AmbitionEng/django-pghistory/compare/2.9.0...3.5.2)

---
updated-dependencies:
- dependency-name: django-pghistory
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 16:50:38 +00:00
dependabot[bot]
7e8c40db0d [DEPENDABOT] Update: Bump pytest-django from 4.9.0 to 4.10.0
Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.9.0 to 4.10.0.
- [Release notes](https://github.com/pytest-dev/pytest-django/releases)
- [Changelog](https://github.com/pytest-dev/pytest-django/blob/main/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.9.0...v4.10.0)

---
updated-dependencies:
- dependency-name: pytest-django
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 16:50:33 +00:00
dependabot[bot]
7211c17aae [DEPENDABOT] Update: Bump django-tailwind-cli from 2.21.1 to 4.0.1
Bumps [django-tailwind-cli](https://github.com/django-commons/django-tailwind-cli) from 2.21.1 to 4.0.1.
- [Release notes](https://github.com/django-commons/django-tailwind-cli/releases)
- [Changelog](https://github.com/django-commons/django-tailwind-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/django-commons/django-tailwind-cli/compare/2.21.1...4.0.1)

---
updated-dependencies:
- dependency-name: django-tailwind-cli
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 16:50:31 +00:00
dependabot[bot]
a16b0444d4 [DEPENDABOT] Update: Bump django-allauth from 65.4.0 to 65.4.1
Bumps [django-allauth](https://github.com/sponsors/pennersr) from 65.4.0 to 65.4.1.
- [Commits](https://github.com/sponsors/pennersr/commits)

---
updated-dependencies:
- dependency-name: django-allauth
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 16:50:27 +00:00
dependabot[bot]
a01cda306e [DEPENDABOT] Update: Bump django-htmx from 1.21.0 to 1.22.0
Bumps [django-htmx](https://github.com/adamchainz/django-htmx) from 1.21.0 to 1.22.0.
- [Changelog](https://github.com/adamchainz/django-htmx/blob/main/docs/changelog.rst)
- [Commits](https://github.com/adamchainz/django-htmx/compare/1.21.0...1.22.0)

---
updated-dependencies:
- dependency-name: django-htmx
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 16:50:22 +00:00
dependabot[bot]
808deb82e2 [DEPENDABOT] Update: Bump whitenoise from 6.8.2 to 6.9.0
Bumps [whitenoise](https://github.com/evansd/whitenoise) from 6.8.2 to 6.9.0.
- [Changelog](https://github.com/evansd/whitenoise/blob/main/docs/changelog.rst)
- [Commits](https://github.com/evansd/whitenoise/compare/6.8.2...6.9.0)

---
updated-dependencies:
- dependency-name: whitenoise
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 16:50:19 +00:00
dependabot[bot]
2d2d832e07 [DEPENDABOT] Update Actions: Bump actions/setup-python from 4 to 5
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-10 16:41:58 +00:00
pacnpal
b4c474c496 Refactor URL patterns for parks and rides; update context fields in models to use ForeignKey for pghistory.Context 2025-02-10 10:54:34 -05:00
pacnpal
9ed28b15b4 Create initial migration for HistoricalSlug model; update foreign key references to use settings.AUTH_USER_MODEL and define unique constraints 2025-02-10 00:20:27 -05:00
pacnpal
4b32580b13 Update migration files for Django 5.1.4; remove obsolete merge migrations and adjust history tracking context in templates 2025-02-10 00:11:29 -05:00
pacnpal
228eeeb3c8 Add merge migrations for parks, companies, and moderation apps; update EmailConfiguration, Review, Photo, TopList, and TopListItem models to use pghistory for historical tracking 2025-02-09 16:00:10 -05:00
pacnpal
b7f6c60682 Refactor model imports and update admin classes to use pghistory for historical tracking; replace HistoricalModel with TrackedModel in relevant models 2025-02-09 11:20:40 -05:00
pacnpal
7ecf43f1a4 Add history tracking functionality using django-pghistory; implement views, templates, and middleware for event serialization and context management 2025-02-09 09:52:19 -05:00
pacnpal
a148d34cf9 Implement historical tracking using django-pghistory; add middleware for context capture and update model architecture 2025-02-08 21:18:44 -05:00
pacnpal
71b73522ae Revert "Add version control system functionality with branch management, history tracking, and merge operations"
This reverts commit f3d28817a5.
2025-02-08 17:37:30 -05:00
pacnpal
03f9df4bab Refactor comments app to use mixins for comment functionality; update admin interfaces and add historical model fixes 2025-02-08 16:33:55 -05:00
pacnpal
75f5b07129 Add comments app with models, views, and tests; integrate comments into existing models 2025-02-07 21:58:02 -05:00
pacnpal
86ae24bbac Add comprehensive evaluation and recommendations for version control system 2025-02-07 13:57:07 -05:00
pacnpal
0e0ed01cee Add comment and reply functionality with preview and notification templates 2025-02-07 13:13:49 -05:00
pacnpal
2c4d2daf34 Add OWASP compliance mapping and security test case templates, and document version control implementation phases 2025-02-07 10:51:11 -05:00
pacnpal
d353f24f9d Add timezone utility and current branch signal to enhance park detail functionality 2025-02-06 20:38:38 -05:00
pacnpal
9c65df12bb Enhance type safety in version control system by adding UserModel TypeVar, improving type hints in managers.py and utils.py, and ensuring consistent type imports. 2025-02-06 20:35:30 -05:00
pacnpal
ecf94bf84e Add version control context processor and integrate map functionality with dedicated JavaScript 2025-02-06 20:06:10 -05:00
pacnpal
f3d28817a5 Add version control system functionality with branch management, history tracking, and merge operations 2025-02-06 19:29:23 -05:00
pacnpal
6fa807f4b6 Add photo gallery functionality with file upload, sharing, and fullscreen display features 2025-02-06 14:20:48 -05:00
pacnpal
323aa561a5 Refactor authentication settings and enhance frontend moderation panel with performance optimizations, loading states, error handling, mobile responsiveness, and accessibility improvements 2025-02-06 14:20:12 -05:00
pacnpal
7d25d6f992 Update Django and dependencies, add app path configuration 2025-02-06 13:58:57 -05:00
pacnpal
19852207f6 Refactor CI workflow to use Homebrew for GDAL installation and update Python version to 3.13.1 2025-02-06 11:55:43 -05:00
pacnpal
185af7fd17 Refactor CI workflow to install pip using get-pip.py and ensure system package compatibility 2025-02-06 11:47:17 -05:00
pacnpal
768f05b783 Improve CI workflow by setting up Python and upgrading pip, setuptools, and wheel before installing dependencies 2025-02-06 11:45:29 -05:00
pacnpal
411c6f6f68 Enhance CI workflow by configuring Django GDAL settings and adding debug steps for GDAL installation verification 2025-02-06 11:26:36 -05:00
pacnpal
789a6386a5 Update GDAL installation steps in CI workflow for improved compatibility 2025-02-06 10:45:04 -05:00
pacnpal
dbd76785b5 Add GDAL installation and verification steps to CI workflow 2025-02-06 10:35:56 -05:00
pacnpal
4215e14b5e Merge pull request #25 from pacnpal/pixeebot/drip-2025-02-06-pixee-python/remove-unnecessary-f-str
Remove Unnecessary F-strings
2025-02-05 23:04:48 -05:00
pixeebot[bot]
dee7c61320 Remove Unnecessary F-strings 2025-02-06 03:53:13 +00:00
pacnpal
2f26061170 Add four GIF files to media submissions 2025-02-05 18:48:36 -05:00
pacnpal
bc68eaf4d9 Add auto-approval for moderator submissions and update submission handling 2025-02-05 18:47:23 -05:00
pacnpal
1ef38f4a96 Add three GIF files to media submissions 2025-02-05 18:23:45 -05:00
pacnpal
1f3f94702e Add LocationForm import to LocationSearchView and ignore type check for piexif import 2025-02-05 18:10:07 -05:00
pacnpal
63b484b724 Merge pull request #24 from pacnpal/dependabot/pip/black-25.1.0
[DEPENDABOT] Update: Bump black from 24.10.0 to 25.1.0
2025-02-05 18:03:44 -05:00
pacnpal
1182e894e3 Merge branch 'main' into dependabot/pip/black-25.1.0 2025-02-05 18:01:31 -05:00
pacnpal
cda755ea59 Merge pull request #23 from pacnpal/dependabot/pip/isort-tw-6.0.0
[DEPENDABOT] Update: Update isort requirement from ^5.13.0 to ^6.0.0
2025-02-05 18:00:24 -05:00
pacnpal
8bbfce3f2a Merge pull request #22 from pacnpal/pixeebot/drip-2025-01-28-pixee-python/add-requests-timeouts
Add timeout to `requests` calls
2025-02-05 18:00:10 -05:00
pacnpal
0e97fdc96b Merge pull request #21 from pacnpal/dependabot/pip/django-simple-history-3.8.0
[DEPENDABOT] Update: Bump django-simple-history from 3.7.0 to 3.8.0
2025-02-05 17:59:59 -05:00
pacnpal
ebc38228e6 Fix filter query in cleanup_test_data command to use correct field for reviews 2025-02-05 17:58:40 -05:00
dependabot[bot]
45c40c720d [DEPENDABOT] Update: Bump black from 24.10.0 to 25.1.0
Bumps [black](https://github.com/psf/black) from 24.10.0 to 25.1.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.10.0...25.1.0)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 16:57:28 +00:00
dependabot[bot]
696d26acdd [DEPENDABOT] Update: Update isort requirement from ^5.13.0 to ^6.0.0
Updates the requirements on [isort](https://github.com/pycqa/isort) to permit the latest version.
- [Release notes](https://github.com/pycqa/isort/releases)
- [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md)
- [Commits](https://github.com/pycqa/isort/compare/5.13.0...6.0.0)

---
updated-dependencies:
- dependency-name: isort
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-03 16:57:15 +00:00
pixeebot[bot]
96857ad1d4 Add timeout to requests calls 2025-01-28 03:09:49 +00:00
dependabot[bot]
ef40184e07 [DEPENDABOT] Update: Bump django-simple-history from 3.7.0 to 3.8.0
Bumps [django-simple-history](https://github.com/jazzband/django-simple-history) from 3.7.0 to 3.8.0.
- [Release notes](https://github.com/jazzband/django-simple-history/releases)
- [Changelog](https://github.com/jazzband/django-simple-history/blob/master/CHANGES.rst)
- [Commits](https://github.com/jazzband/django-simple-history/compare/3.7.0...3.8.0)

---
updated-dependencies:
- dependency-name: django-simple-history
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-27 16:46:08 +00:00
pacnpal
7aa706d12a Merge pull request #16 from pacnpal/dependabot/pip/dj-rest-auth-7.0.1 2025-01-21 21:59:57 -05:00
pacnpal
de6146f812 Merge pull request #17 from pacnpal/dependabot/pip/pillow-11.1.0 2025-01-21 21:59:46 -05:00
pacnpal
1bfbe4a8b4 Merge pull request #19 from pacnpal/dependabot/pip/django-5.1.5 2025-01-21 21:59:37 -05:00
pacnpal
209c3e4d21 Merge pull request #20 from pacnpal/dependabot/pip/reactivated-tw-0.46.0 2025-01-21 21:58:30 -05:00
dependabot[bot]
886b275f65 [DEPENDABOT] Update: Update reactivated requirement
Updates the requirements on [reactivated](https://github.com/silviogutierrez/django-react) to permit the latest version.
- [Commits](https://github.com/silviogutierrez/django-react/compare/v0.45.3...v0.46.0)

---
updated-dependencies:
- dependency-name: reactivated
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-20 16:28:36 +00:00
dependabot[bot]
c9ebf4c833 [DEPENDABOT] Update: Bump django from 5.1.4 to 5.1.5
Bumps [django](https://github.com/django/django) from 5.1.4 to 5.1.5.
- [Commits](https://github.com/django/django/compare/5.1.4...5.1.5)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-20 16:28:25 +00:00
dependabot[bot]
672749d109 [DEPENDABOT] Update: Bump pillow from 11.0.0 to 11.1.0
Bumps [pillow](https://github.com/python-pillow/Pillow) from 11.0.0 to 11.1.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/11.0.0...11.1.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-06 16:31:53 +00:00
dependabot[bot]
a5c3e56046 [DEPENDABOT] Update: Bump dj-rest-auth from 7.0.0 to 7.0.1
Bumps [dj-rest-auth](https://github.com/iMerica/dj-rest-auth) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/iMerica/dj-rest-auth/releases)
- [Commits](https://github.com/iMerica/dj-rest-auth/compare/7.0.0...7.0.1)

---
updated-dependencies:
- dependency-name: dj-rest-auth
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-06 16:31:42 +00:00
pacnpal
d728ba6e9c Merge pull request #14 from pacnpal/dependabot/pip/django-allauth-65.3.1 2025-01-02 22:31:24 -05:00
pacnpal
f819a1f07c Merge pull request #13 from pacnpal/pixeebot/drip-2024-12-26-pixee-python/secure-random 2025-01-02 22:30:39 -05:00
dependabot[bot]
d91f79e29c [DEPENDABOT] Update: Bump django-allauth from 65.3.0 to 65.3.1
Bumps [django-allauth](https://github.com/sponsors/pennersr) from 65.3.0 to 65.3.1.
- [Commits](https://github.com/sponsors/pennersr/commits)

---
updated-dependencies:
- dependency-name: django-allauth
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-30 16:59:20 +00:00
pixeebot[bot]
304812d43f Secure Source of Randomness 2024-12-26 03:11:52 +00:00
pacnpal
3f7296d7a5 ygeah 2024-12-24 14:32:26 -05:00
pacnpal
e60f73de9d Add test setup, cleanup, and fixtures 2024-12-24 14:32:16 -05:00
pacnpal
af7ea6b4ce Add comprehensive e2e tests with Playwright 2024-12-24 14:29:32 -05:00
pacnpal
36478c7a1b Fix dependencies and GeoDjango configuration 2024-12-24 14:24:18 -05:00
pacnpal
c8628984e0 Merge pull request #1 from pacnpal/dependabot/github_actions/actions/setup-python-5
[DEPENDABOT] Update Actions: Bump actions/setup-python from 3 to 5
2024-12-24 11:18:44 -05:00
pacnpal
1bfe08a0a7 Merge pull request #2 from pacnpal/dependabot/pip/django-allauth-65.3.0
[DEPENDABOT] Update: Bump django-allauth from 65.2.0 to 65.3.0
2024-12-24 11:18:32 -05:00
pacnpal
901a1c421d Merge pull request #3 from pacnpal/dependabot/pip/channels-4.2.0
[DEPENDABOT] Update: Bump channels from 4.1.0 to 4.2.0
2024-12-24 11:18:21 -05:00
dependabot[bot]
280ad4d6da [DEPENDABOT] Update: Bump channels from 4.1.0 to 4.2.0
Bumps [channels](https://github.com/django/channels) from 4.1.0 to 4.2.0.
- [Changelog](https://github.com/django/channels/blob/main/CHANGELOG.txt)
- [Commits](https://github.com/django/channels/compare/4.1.0...4.2.0)

---
updated-dependencies:
- dependency-name: channels
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-24 16:17:52 +00:00
dependabot[bot]
9634bac155 [DEPENDABOT] Update: Bump django-allauth from 65.2.0 to 65.3.0
Bumps [django-allauth](https://github.com/sponsors/pennersr) from 65.2.0 to 65.3.0.
- [Commits](https://github.com/sponsors/pennersr/commits)

---
updated-dependencies:
- dependency-name: django-allauth
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-24 16:17:15 +00:00
pacnpal
69094a9af8 Merge pull request #4 from pacnpal/dependabot/pip/django-5.1.4
[DEPENDABOT] Update: Bump django from 5.1.3 to 5.1.4
2024-12-24 11:17:01 -05:00
pacnpal
d338917ca1 Merge pull request #5 from pacnpal/dependabot/pip/pytest-8.3.4
[DEPENDABOT] Update: Bump pytest from 8.3.3 to 8.3.4
2024-12-24 11:16:52 -05:00
pacnpal
5acc74d34c Merge pull request #6 from pacnpal/dependabot/pip/channels-redis-4.2.1
[DEPENDABOT] Update: Bump channels-redis from 4.2.0 to 4.2.1
2024-12-24 11:16:41 -05:00
pacnpal
8b7ad53cbd Merge pull request #7 from pacnpal/dependabot/pip/pyjwt-2.10.1
[DEPENDABOT] Update: Bump pyjwt from 2.9.0 to 2.10.1
2024-12-24 11:16:07 -05:00
pacnpal
7553752f0d Update review.yml 2024-12-24 11:14:13 -05:00
dependabot[bot]
1ca84208ef [DEPENDABOT] Update: Bump django from 5.1.3 to 5.1.4
Bumps [django](https://github.com/django/django) from 5.1.3 to 5.1.4.
- [Commits](https://github.com/django/django/compare/5.1.3...5.1.4)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-24 16:03:05 +00:00
pacnpal
fdd7a4fcf1 Merge pull request #8 from pacnpal/dependabot/pip/django-tailwind-cli-2.21.1
[DEPENDABOT] Update: Bump django-tailwind-cli from 2.19.0 to 2.21.1
2024-12-24 11:02:08 -05:00
pacnpal
ae8710c157 Merge pull request #12 from pacnpal/dependabot/pip/reactivated-tw-0.45.3
[DEPENDABOT] Update: Update reactivated requirement from ^0.41.1 to ^0.45.3
2024-12-24 11:00:31 -05:00
pacnpal
56d9174bb5 Update and rename main.yml to review.yml 2024-12-24 10:59:19 -05:00
pacnpal
5c62b41070 Update main.yml 2024-12-24 10:51:01 -05:00
pacnpal
8014bcc368 Create main.yml 2024-12-24 10:49:55 -05:00
dependabot[bot]
1a60658f17 [DEPENDABOT] Update: Update reactivated requirement
Updates the requirements on [reactivated](https://github.com/silviogutierrez/django-react) to permit the latest version.
- [Commits](https://github.com/silviogutierrez/django-react/compare/v0.41.1...v0.45.3)

---
updated-dependencies:
- dependency-name: reactivated
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-23 16:44:37 +00:00
dependabot[bot]
311c0e999c [DEPENDABOT] Update: Bump django-tailwind-cli from 2.19.0 to 2.21.1
Bumps [django-tailwind-cli](https://github.com/django-commons/django-tailwind-cli) from 2.19.0 to 2.21.1.
- [Release notes](https://github.com/django-commons/django-tailwind-cli/releases)
- [Changelog](https://github.com/django-commons/django-tailwind-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/django-commons/django-tailwind-cli/compare/2.19.0...2.21.1)

---
updated-dependencies:
- dependency-name: django-tailwind-cli
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-10 11:45:12 +00:00
dependabot[bot]
6af9f7332a [DEPENDABOT] Update: Bump pyjwt from 2.9.0 to 2.10.1
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.9.0 to 2.10.1.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.9.0...2.10.1)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-10 11:44:58 +00:00
dependabot[bot]
7c81d8e8eb [DEPENDABOT] Update: Bump channels-redis from 4.2.0 to 4.2.1
Bumps [channels-redis](https://github.com/django/channels_redis) from 4.2.0 to 4.2.1.
- [Changelog](https://github.com/django/channels_redis/blob/main/CHANGELOG.txt)
- [Commits](https://github.com/django/channels_redis/compare/4.2.0...4.2.1)

---
updated-dependencies:
- dependency-name: channels-redis
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-10 11:44:55 +00:00
dependabot[bot]
04daf9573b [DEPENDABOT] Update: Bump pytest from 8.3.3 to 8.3.4
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.3.3 to 8.3.4.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-10 11:44:52 +00:00
dependabot[bot]
96e2f097cd [DEPENDABOT] Update Actions: Bump actions/setup-python from 3 to 5
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 3 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-10 11:44:15 +00:00
374 changed files with 32160 additions and 5335 deletions

55
.clinerules Normal file
View File

@@ -0,0 +1,55 @@
# Project Startup Rules
## Development Server
IMPORTANT: Always follow these instructions exactly when starting the development server:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
```
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
## Package Management
IMPORTANT: When a Python package is needed, only use UV to add it:
```bash
uv add <package>
```
Do not attempt to install packages using any other method.
## Django Management Commands
IMPORTANT: When running any Django manage.py commands (migrations, shell, etc.), always use UV:
```bash
uv run manage.py <command>
```
This applies to all management commands including but not limited to:
- Making migrations: `uv run manage.py makemigrations`
- Applying migrations: `uv run manage.py migrate`
- Creating superuser: `uv run manage.py createsuperuser`
- Starting shell: `uv run manage.py shell`
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.
## Entity Relationship Rules
IMPORTANT: Follow these entity relationship patterns consistently:
# Park Relationships
- Parks MUST have an Operator (required relationship)
- Parks MAY have a PropertyOwner (optional, usually same as Operator)
- Parks CANNOT directly reference Company entities
# Ride Relationships
- Rides MUST belong to a Park (required relationship)
- Rides MAY have a Manufacturer (optional relationship)
- Rides MAY have a Designer (optional relationship)
- Rides CANNOT directly reference Company entities
# Entity Definitions
- Operators: Companies that operate theme parks (replaces Company.owner)
- PropertyOwners: Companies that own park property (new concept, optional)
- Manufacturers: Companies that manufacture rides (replaces Company for rides)
- Designers: Companies/individuals that design rides (existing concept)
# Relationship Constraints
- Operator and PropertyOwner are usually the same entity but CAN be different
- Manufacturers and Designers are distinct concepts and should not be conflated
- All entity relationships should use proper foreign keys with appropriate null/blank settings

View File

@@ -2,29 +2,40 @@ name: Django CI
on:
push:
branches: [ "main" ]
branches: [ main ]
pull_request:
branches: [ "main" ]
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
test:
runs-on: ${{ matrix.os }}
strategy:
max-parallel: 4
matrix:
python-version: [3.12]
os: [ubuntu-latest, macos-latest]
python-version: [3.13.1]
steps:
- uses: actions/checkout@v4
- name: Install Homebrew on Linux
if: runner.os == 'Linux'
run: |
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH
- name: Install GDAL with Homebrew
run: brew install gdal
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
run: |
python manage.py test

34
.github/workflows/review.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Claude Code Review
permissions:
contents: read
pull-requests: write
on:
# Run on new/updated PRs
pull_request:
types: [opened, reopened, synchronize]
# Allow manual triggers for existing PRs
workflow_dispatch:
inputs:
pr_number:
description: 'Pull Request Number'
required: true
type: string
jobs:
code-review:
runs-on: ubuntu-latest
environment: development_environment
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Review
uses: pacnpal/claude-code-review@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
anthropic-key: ${{ secrets.ANTHROPIC_API_KEY }}
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}

20
.roomodes Normal file

File diff suppressed because one or more lines are too long

370
README.md Normal file
View File

@@ -0,0 +1,370 @@
# ThrillWiki Development Environment Setup
ThrillWiki is a modern Django web application for theme park and roller coaster enthusiasts, featuring a sophisticated dark theme design with purple-to-blue gradients, HTMX interactivity, and comprehensive park/ride information management.
## 🏗️ Technology Stack
- **Backend**: Django 5.0+ with GeoDjango (PostGIS)
- **Frontend**: HTMX + Alpine.js + Tailwind CSS
- **Database**: PostgreSQL with PostGIS extension
- **Package Management**: UV (Python package manager)
- **Authentication**: Django Allauth with Google/Discord OAuth
- **Styling**: Tailwind CSS with custom dark theme
- **History Tracking**: django-pghistory for audit trails
- **Testing**: Pytest + Playwright for E2E testing
## 📋 Prerequisites
### Required Software
1. **Python 3.11+**
```bash
python --version # Should be 3.11 or higher
```
2. **UV Package Manager**
```bash
# Install UV if not already installed
curl -LsSf https://astral.sh/uv/install.sh | sh
# or
pip install uv
```
3. **PostgreSQL with PostGIS**
```bash
# macOS (Homebrew)
brew install postgresql postgis
# Ubuntu/Debian
sudo apt-get install postgresql postgresql-contrib postgis
# Start PostgreSQL service
brew services start postgresql # macOS
sudo systemctl start postgresql # Linux
```
4. **GDAL/GEOS Libraries** (for GeoDjango)
```bash
# macOS (Homebrew)
brew install gdal geos
# Ubuntu/Debian
sudo apt-get install gdal-bin libgdal-dev libgeos-dev
```
5. **Node.js** (for Tailwind CSS)
```bash
# Install Node.js 18+ for Tailwind CSS compilation
node --version # Should be 18 or higher
```
## 🚀 Quick Start
### 1. Clone and Setup Project
```bash
# Clone the repository
git clone <repository-url>
cd thrillwiki_django_no_react
# Install Python dependencies using UV
uv sync
```
### 2. Database Setup
```bash
# Create PostgreSQL database and user
createdb thrillwiki
createuser wiki
# Connect to PostgreSQL and setup
psql postgres
```
In the PostgreSQL shell:
```sql
-- Set password for wiki user
ALTER USER wiki WITH PASSWORD 'thrillwiki';
-- Grant privileges
GRANT ALL PRIVILEGES ON DATABASE thrillwiki TO wiki;
-- Enable PostGIS extension
\c thrillwiki
CREATE EXTENSION postgis;
\q
```
### 3. Environment Configuration
The project uses these database settings (configured in [`thrillwiki/settings.py`](thrillwiki/settings.py)):
```python
DATABASES = {
"default": {
"ENGINE": "django.contrib.gis.db.backends.postgis",
"NAME": "thrillwiki",
"USER": "wiki",
"PASSWORD": "thrillwiki",
"HOST": "192.168.86.3", # Update to your PostgreSQL host
"PORT": "5432",
}
}
```
**Important**: Update the `HOST` setting in [`thrillwiki/settings.py`](thrillwiki/settings.py) to match your PostgreSQL server location:
- Use `"localhost"` or `"127.0.0.1"` for local development
- Current setting is `"192.168.86.3"` - update this to your PostgreSQL server IP
- For local development, change to `"localhost"` in settings.py
### 4. Database Migration
```bash
# Run database migrations
uv run manage.py migrate
# Create a superuser account
uv run manage.py createsuperuser
```
**Note**: If you're setting up for local development, first update the database HOST in [`thrillwiki/settings.py`](thrillwiki/settings.py) from `"192.168.86.3"` to `"localhost"` before running migrations.
### 5. Start Development Server
**CRITICAL**: Always use this exact command sequence for starting the development server:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
```
This command:
- Kills any existing processes on port 8000
- Cleans Python cache files
- Starts Tailwind CSS compilation
- Runs the Django development server
The application will be available at: http://localhost:8000
## 🛠️ Development Workflow
### Package Management
**ALWAYS use UV for package management**:
```bash
# Add new Python packages
uv add <package-name>
# Add development dependencies
uv add --dev <package-name>
# Never use pip install - always use UV
```
### Django Management Commands
**ALWAYS use UV for Django commands**:
```bash
# Correct way to run Django commands
uv run manage.py <command>
# Examples:
uv run manage.py makemigrations
uv run manage.py migrate
uv run manage.py shell
uv run manage.py createsuperuser
uv run manage.py collectstatic
# NEVER use these patterns:
# python manage.py <command> ❌ Wrong
# uv run python manage.py <command> ❌ Wrong
```
### CSS Development
The project uses Tailwind CSS with a custom dark theme. CSS files are located in:
- Source: [`static/css/src/input.css`](static/css/src/input.css)
- Compiled: [`static/css/`](static/css/) (auto-generated)
Tailwind automatically compiles when using the `tailwind runserver` command.
## 🏗️ Project Structure
```
thrillwiki_django_no_react/
├── accounts/ # User account management
├── analytics/ # Analytics and tracking
├── companies/ # Theme park companies
├── core/ # Core application logic
├── designers/ # Ride designers
├── history/ # History timeline features
├── location/ # Geographic location handling
├── media/ # Media file management
├── moderation/ # Content moderation
├── parks/ # Theme park management
├── reviews/ # User reviews
├── rides/ # Roller coaster/ride management
├── search/ # Search functionality
├── static/ # Static assets (CSS, JS, images)
├── templates/ # Django templates
├── thrillwiki/ # Main Django project settings
├── memory-bank/ # Development documentation
└── .clinerules # Project development rules
```
## 🔧 Key Features
### Authentication System
- Django Allauth integration
- Google OAuth authentication
- Discord OAuth authentication
- Custom user profiles with avatars
### Geographic Features
- PostGIS integration for location data
- Interactive park maps
- Location-based search and filtering
### Content Management
- Park and ride information management
- Photo galleries with upload capabilities
- User-generated reviews and ratings
- Content moderation system
### Modern Frontend
- HTMX for dynamic interactions
- Alpine.js for client-side behavior
- Tailwind CSS with custom dark theme
- Responsive design (mobile-first)
## 🧪 Testing
### Running Tests
```bash
# Run Python tests
uv run pytest
# Run with coverage
uv run coverage run -m pytest
uv run coverage report
# Run E2E tests with Playwright
uv run pytest tests/e2e/
```
### Test Structure
- Unit tests: Located within each app's `tests/` directory
- E2E tests: [`tests/e2e/`](tests/e2e/)
- Test fixtures: [`tests/fixtures/`](tests/fixtures/)
## 📚 Documentation
### Memory Bank System
The project uses a comprehensive documentation system in [`memory-bank/`](memory-bank/):
- [`memory-bank/activeContext.md`](memory-bank/activeContext.md) - Current development context
- [`memory-bank/documentation/design-system.md`](memory-bank/documentation/design-system.md) - Design system documentation
- [`memory-bank/features/`](memory-bank/features/) - Feature-specific documentation
- [`memory-bank/testing/`](memory-bank/testing/) - Testing documentation and results
### Key Documentation Files
- [Design System](memory-bank/documentation/design-system.md) - UI/UX guidelines and patterns
- [Authentication System](memory-bank/features/auth/) - OAuth and user management
- [Layout Optimization](memory-bank/projects/) - Responsive design implementations
## 🚨 Important Development Rules
### Critical Commands
1. **Server Startup**: Always use the full command sequence:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
```
2. **Package Management**: Only use UV:
```bash
uv add <package> # ✅ Correct
pip install <package> # ❌ Wrong
```
3. **Django Commands**: Always prefix with `uv run`:
```bash
uv run manage.py <command> # ✅ Correct
python manage.py <command> # ❌ Wrong
```
### Database Configuration
- Ensure PostgreSQL is running before starting development
- PostGIS extension must be enabled
- Update database host settings for your environment
### GeoDjango Requirements
- GDAL and GEOS libraries must be properly installed
- Library paths are configured in [`thrillwiki/settings.py`](thrillwiki/settings.py) for macOS Homebrew
- Current paths: `/opt/homebrew/lib/libgdal.dylib` and `/opt/homebrew/lib/libgeos_c.dylib`
- May need adjustment based on your system's library locations (Linux users will need different paths)
## 🔍 Troubleshooting
### Common Issues
1. **PostGIS Extension Error**
```bash
# Connect to database and enable PostGIS
psql thrillwiki
CREATE EXTENSION postgis;
```
2. **GDAL/GEOS Library Not Found**
```bash
# macOS (Homebrew): Current paths in settings.py
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
# Linux: Update paths in settings.py to something like:
# GDAL_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgdal.so"
# GEOS_LIBRARY_PATH = "/usr/lib/x86_64-linux-gnu/libgeos_c.so"
# Find your library locations
find /usr -name "libgdal*" 2>/dev/null
find /usr -name "libgeos*" 2>/dev/null
find /opt -name "libgdal*" 2>/dev/null
find /opt -name "libgeos*" 2>/dev/null
```
3. **Port 8000 Already in Use**
```bash
# Kill existing processes
lsof -ti :8000 | xargs kill -9
```
4. **Tailwind CSS Not Compiling**
```bash
# Ensure Node.js is installed and use the full server command
node --version
uv run manage.py tailwind runserver
```
### Getting Help
1. Check the [`memory-bank/`](memory-bank/) documentation for detailed feature information
2. Review [`memory-bank/testing/`](memory-bank/testing/) for known issues and solutions
3. Ensure all prerequisites are properly installed
4. Verify database connection and PostGIS extension
## 🎯 Next Steps
After successful setup:
1. **Explore the Admin Interface**: http://localhost:8000/admin/
2. **Browse the Application**: http://localhost:8000/
3. **Review Documentation**: Check [`memory-bank/`](memory-bank/) for detailed feature docs
4. **Run Tests**: Ensure everything works with `uv run pytest`
5. **Start Development**: Follow the development workflow guidelines above
---
**Happy Coding!** 🎢✨
For detailed feature documentation and development context, see the [`memory-bank/`](memory-bank/) directory.

View File

@@ -0,0 +1,67 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from reviews.models import Review
from parks.models import Park
from rides.models import Ride
from media.models import Photo
User = get_user_model()
class Command(BaseCommand):
help = "Cleans up test users and data created during e2e testing"
def handle(self, *args, **kwargs):
# Delete test users
test_users = User.objects.filter(username__in=["testuser", "moderator"])
count = test_users.count()
test_users.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
# Delete test reviews
reviews = Review.objects.filter(user__username__in=["testuser", "moderator"])
count = reviews.count()
reviews.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
# Delete test photos
photos = Photo.objects.filter(uploader__username__in=["testuser", "moderator"])
count = photos.count()
photos.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test photos"))
# Delete test parks
parks = Park.objects.filter(name__startswith="Test Park")
count = parks.count()
parks.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test parks"))
# Delete test rides
rides = Ride.objects.filter(name__startswith="Test Ride")
count = rides.count()
rides.delete()
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
# Clean up test files
import os
import glob
# Clean up test uploads
media_patterns = [
"media/uploads/test_*",
"media/avatars/test_*",
"media/park/test_*",
"media/rides/test_*",
]
for pattern in media_patterns:
files = glob.glob(pattern)
for f in files:
try:
os.remove(f)
self.stdout.write(self.style.SUCCESS(f"Deleted {f}"))
except OSError as e:
self.stdout.write(self.style.WARNING(f"Error deleting {f}: {e}"))
self.stdout.write(self.style.SUCCESS("Test data cleanup complete"))

View File

@@ -0,0 +1,56 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
User = get_user_model()
class Command(BaseCommand):
help = "Creates test users for e2e testing"
def handle(self, *args, **kwargs):
# Create regular test user
if not User.objects.filter(username="testuser").exists():
user = User.objects.create_user(
username="testuser",
email="testuser@example.com",
[PASSWORD-REMOVED]",
)
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.username}"))
else:
self.stdout.write(self.style.WARNING("Test user already exists"))
# Create moderator user
if not User.objects.filter(username="moderator").exists():
moderator = User.objects.create_user(
username="moderator",
email="moderator@example.com",
[PASSWORD-REMOVED]",
)
# Create moderator group if it doesn't exist
moderator_group, created = Group.objects.get_or_create(name="Moderators")
# Add relevant permissions
permissions = Permission.objects.filter(
codename__in=[
"change_review",
"delete_review",
"change_park",
"change_ride",
"moderate_photos",
"moderate_comments",
]
)
moderator_group.permissions.add(*permissions)
# Add user to moderator group
moderator.groups.add(moderator_group)
self.stdout.write(
self.style.SUCCESS(f"Created moderator user: {moderator.username}")
)
else:
self.stdout.write(self.style.WARNING("Moderator user already exists"))
self.stdout.write(self.style.SUCCESS("Test users setup complete"))

View File

@@ -22,7 +22,7 @@ class Command(BaseCommand):
self.stdout.write(f'- {site.domain} ({site.name})')
# Show callback URL
callback_url = f'http://localhost:8000/accounts/discord/login/callback/'
callback_url = 'http://localhost:8000/accounts/discord/login/callback/'
self.stdout.write('\nCallback URL to configure in Discord Developer Portal:')
self.stdout.write(callback_url)

View File

@@ -1,9 +1,11 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
@@ -15,6 +17,7 @@ class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
@@ -229,15 +232,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="TopList",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("id", models.BigAutoField(primary_key=True, serialize=False)),
("title", models.CharField(max_length=100)),
(
"category",
@@ -268,6 +263,145 @@ class Migration(migrations.Migration):
"ordering": ["-updated_at"],
},
),
migrations.CreateModel(
name="TopListEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("title", models.CharField(max_length=100)),
(
"category",
models.CharField(
choices=[
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("PK", "Park"),
],
max_length=2,
),
),
("description", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.toplist",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TopListItem",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"top_list",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="accounts.toplist",
),
),
],
options={
"ordering": ["rank"],
},
),
migrations.CreateModel(
name="TopListItemEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=True)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.toplistitem",
),
),
(
"top_list",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="accounts.toplist",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="UserProfile",
fields=[
@@ -318,40 +452,66 @@ class Migration(migrations.Migration):
),
],
),
migrations.CreateModel(
name="TopListItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
pgtrigger.migrations.AddTrigger(
model_name="toplist",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_26546",
table="accounts_toplist",
when="AFTER",
),
),
("object_id", models.PositiveIntegerField()),
("rank", models.PositiveIntegerField()),
("notes", models.TextField(blank=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
pgtrigger.migrations.AddTrigger(
model_name="toplist",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_84849",
table="accounts_toplist",
when="AFTER",
),
),
(
"top_list",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="accounts.toplist",
),
migrations.AlterUniqueTogether(
name="toplistitem",
unique_together={("top_list", "rank")},
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id") VALUES (NEW."content_type_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_56dfc",
table="accounts_toplistitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id") VALUES (NEW."content_type_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
),
),
],
options={
"ordering": ["rank"],
"unique_together": {("top_list", "rank")},
},
),
]

View File

@@ -0,0 +1,93 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="update_update",
),
migrations.AddField(
model_name="toplistitem",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitem",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="toplistitemevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitemevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="toplist",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="toplistitem",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_56dfc",
table="accounts_toplistitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
),
),
),
]

View File

@@ -26,7 +26,7 @@ class TurnstileMixin:
'remoteip': request.META.get('REMOTE_ADDR'),
}
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data)
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
result = response.json()
if not result.get('success'):

View File

@@ -2,22 +2,24 @@ from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
import random
from PIL import Image, ImageDraw, ImageFont
from io import BytesIO
import base64
import os
import secrets
from history_tracking.models import TrackedModel
import pghistory
def generate_random_id(model_class, id_field):
"""Generate a random ID starting at 4 digits, expanding to 5 if needed"""
while True:
# Try to get a 4-digit number first
new_id = str(random.randint(1000, 9999))
new_id = str(secrets.SystemRandom().randint(1000, 9999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
# If all 4-digit numbers are taken, try 5 digits
new_id = str(random.randint(10000, 99999))
new_id = str(secrets.SystemRandom().randint(10000, 99999))
if not model_class.objects.filter(**{id_field: new_id}).exists():
return new_id
@@ -158,7 +160,8 @@ class PasswordReset(models.Model):
verbose_name = "Password Reset"
verbose_name_plural = "Password Resets"
class TopList(models.Model):
@pghistory.track()
class TopList(TrackedModel):
class Categories(models.TextChoices):
ROLLER_COASTER = 'RC', _('Roller Coaster')
DARK_RIDE = 'DR', _('Dark Ride')
@@ -186,7 +189,8 @@ class TopList(models.Model):
def __str__(self):
return f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
class TopListItem(models.Model):
@pghistory.track()
class TopListItem(TrackedModel):
top_list = models.ForeignKey(
TopList,
on_delete=models.CASCADE,

View File

@@ -4,8 +4,8 @@ from django.contrib.auth.models import Group
from django.db import transaction
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
import requests
from .models import User, UserProfile, EmailVerification
from security import safe_requests
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
@@ -31,7 +31,7 @@ def create_user_profile(sender, instance, created, **kwargs):
if avatar_url:
try:
response = requests.get(avatar_url)
response = safe_requests.get(avatar_url, timeout=60)
if response.status_code == 200:
img_temp = NamedTemporaryFile(delete=True)
img_temp.write(response.content)

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.db.models.deletion
from django.db import migrations, models

View File

@@ -0,0 +1,208 @@
# Frontend Architecture Documentation
Last Updated: 2024-02-21
## Core Technologies
### 1. HTMX
- Used for dynamic updates and server interactions
- Enables partial page updates without full reloads
- Integrated with Django backend for seamless data exchange
- Used for form submissions and dynamic content loading
### 2. AlpineJS
- Handles client-side interactivity and state management
- Used for dropdowns, modals, and other interactive components
- Provides reactive data binding and event handling
- Key features used:
- x-data for component state
- x-show/x-if for conditional rendering
- x-model for two-way data binding
- x-on for event handling
### 3. Tailwind CSS
- Utility-first CSS framework for styling
- Custom configuration in tailwind.config.js
- Responsive design utilities
- Dark mode support with class-based implementation
- Custom color scheme with primary/secondary colors
## Styling System
### 1. Base Styles
- Font: Poppins (400, 500, 600, 700 weights)
- Color Scheme:
- Primary: Indigo (#4F46E5)
- Secondary: Rose (#E11D48)
- Gradients for interactive elements
- Dark mode compatible color palette
### 2. Component Classes
- Button Variants:
- .btn-primary: Gradient background with hover effects
- .btn-secondary: Light/dark mode aware styling
- Social login buttons with brand colors
- Form Elements:
- .form-input: Styled input fields
- .form-label: Consistent label styling
- .form-error: Error message styling
- Cards:
- .card: Base card styling with shadows
- .auth-card: Special styling for authentication forms
- Status Badges:
- .status-operating: Green success state
- .status-closed: Red error state
- .status-construction: Yellow warning state
### 3. Layout Components
- Responsive container system
- Grid system using Tailwind's grid utilities
- Flexbox-based navigation and content layouts
- Mobile-first responsive design
## Interactive Components
### 1. Navigation
- Responsive header with mobile menu
- User dropdown menu with authentication states
- Theme toggle (light/dark mode)
- Mobile-optimized navigation drawer
### 2. Forms
- Location autocomplete system
- Form validation with error states
- CSRF protection integration
- File upload handling
### 3. Alerts System
- Timed auto-dismissing alerts
- Slide animations for entry/exit
- Context-aware styling (success, error, info, warning)
- Accessible notifications
### 4. Modal System
- HTMX-powered dynamic content loading
- Alpine.js state management
- Backdrop blur effects
- Keyboard navigation support
## JavaScript Architecture
### 1. Core Functionality
- Theme management with local storage persistence
- HTMX configuration and setup
- Alpine.js component initialization
- Event delegation and handling
### 2. Location Autocomplete
- Progressive enhancement for location fields
- Country/Region/City hierarchical selection
- Dynamic filtering based on parent selections
- AJAX-powered suggestions
### 3. Form Handling
- Client-side validation
- File upload preview
- Dynamic form updates
- Error state management
## Performance Optimizations
### 1. Asset Loading
- Deferred script loading
- Preloaded critical assets
- Minified production assets
- Cached static resources
### 2. Rendering
- Progressive enhancement
- Partial page updates
- Lazy loading of images
- Optimized animation performance
### 3. State Management
- Efficient DOM updates
- Debounced search inputs
- Throttled scroll handlers
- Memory leak prevention
## Accessibility Features
### 1. Semantic HTML
- Proper heading hierarchy
- ARIA labels and roles
- Semantic landmark regions
- Meaningful alt text
### 2. Keyboard Navigation
- Focus management
- Skip links
- Keyboard shortcuts
- Focus trapping in modals
### 3. Screen Readers
- ARIA live regions for alerts
- Status role for notifications
- Description text for icons
- Form label associations
## Development Workflow
### 1. CSS Organization
- Utility-first approach
- Component-specific styles
- Shared design tokens
- Dark mode variants
### 2. JavaScript Patterns
- Event delegation
- Component encapsulation
- State management
- Error handling
### 3. Testing Considerations
- Browser compatibility
- Responsive design testing
- Accessibility testing
- Performance monitoring
## Browser Support
### 1. Supported Browsers
- Chrome (latest 2 versions)
- Firefox (latest 2 versions)
- Safari (latest 2 versions)
- Edge (latest version)
### 2. Fallbacks
- Graceful degradation
- No-script support
- Legacy browser handling
- Progressive enhancement
## Security Measures
### 1. CSRF Protection
- Token validation
- Secure form submission
- Protected AJAX requests
- Session handling
### 2. XSS Prevention
- Content sanitization
- Escaped output
- Secure cookie handling
- Input validation
## Future Considerations
### 1. Potential Improvements
- Component library development
- Enhanced type checking
- Performance monitoring
- Automated testing
### 2. Maintenance
- Regular dependency updates
- Browser compatibility checks
- Performance optimization
- Security audits

View File

@@ -20,22 +20,22 @@
### Frontend Technologies
1. HTMX
- Dynamic updates
- Partial rendering
- Server-side processing
- Progressive enhancement
- Dynamic updates and server interactions
- Partial rendering and progressive enhancement
- Server-side processing and form handling
- See frontendArchitecture.md for detailed implementation
2. AlpineJS
- UI state management
- Component behavior
- Event handling
- DOM manipulation
- UI state management and reactivity
- Component behavior and lifecycle
- Event handling and DOM manipulation
- See frontendArchitecture.md for component patterns
3. Tailwind CSS
- Utility-first styling
- Component design
- Responsive layouts
- Custom configuration
- Utility-first styling with custom configuration
- Component design system
- Responsive layouts and dark mode support
- See frontendArchitecture.md for styling guide
## Integration Patterns
@@ -87,16 +87,24 @@
### Frontend Libraries
1. CSS Framework
- Tailwind CSS
- Custom plugins
- Theme configuration
- Utility classes
- Tailwind CSS with custom configuration
- Theme system with light/dark mode support
- Component-specific style patterns
- See frontendArchitecture.md for complete styling guide
2. JavaScript
- AlpineJS core
- HTMX library
- Utility functions
- Custom components
- AlpineJS for reactive components
- HTMX for server interactions
- Location autocomplete system
- Alert and modal components
- See frontendArchitecture.md for component documentation
3. UI Components
- Form elements and validation
- Navigation and menus
- Status indicators and badges
- Modal and alert system
- See frontendArchitecture.md for implementation details
## Infrastructure Choices
@@ -143,10 +151,14 @@
### Technology Limitations
1. Frontend
- HTMX/AlpineJS only
- No additional frameworks
- Browser compatibility
- Performance requirements
- HTMX/AlpineJS only (no React/Vue/Angular)
- Progressive enhancement approach required
- Must support latest 2 versions of major browsers
- See frontendArchitecture.md for detailed browser support
- Performance targets:
* First contentful paint < 1.5s
* Time to interactive < 2s
* Core Web Vitals compliance
2. Backend
- Django version constraints

View File

@@ -1,17 +0,0 @@
from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin
from .models import Company, Manufacturer
@admin.register(Company)
class CompanyAdmin(SimpleHistoryAdmin):
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
search_fields = ('name', 'headquarters', 'description')
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at')
@admin.register(Manufacturer)
class ManufacturerAdmin(SimpleHistoryAdmin):
list_display = ('id', 'name', 'headquarters', 'website', 'created_at')
search_fields = ('name', 'headquarters', 'description')
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at')

View File

@@ -1,9 +0,0 @@
from django.apps import AppConfig
class CompaniesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'companies'
verbose_name = 'Companies'
def ready(self):
import companies.signals # noqa

View File

@@ -1,46 +0,0 @@
from django import forms
from .models import Company, Manufacturer
class CompanyForm(forms.ModelForm):
class Meta:
model = Company
fields = ['name', 'headquarters', 'website', 'description']
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'headquarters': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'e.g., Orlando, Florida, United States'
}),
'website': forms.URLInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'https://example.com'
}),
'description': forms.Textarea(attrs={
'rows': 4,
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
}
class ManufacturerForm(forms.ModelForm):
class Meta:
model = Manufacturer
fields = ['name', 'headquarters', 'website', 'description']
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'headquarters': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'e.g., Altoona, Pennsylvania, United States'
}),
'website': forms.URLInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'https://example.com'
}),
'description': forms.Textarea(attrs={
'rows': 4,
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
}

View File

@@ -1,66 +0,0 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Company",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)),
("website", models.URLField(blank=True)),
("headquarters", models.CharField(blank=True, max_length=255)),
("description", models.TextField(blank=True)),
("total_parks", models.IntegerField(default=0)),
("total_rides", models.IntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name_plural": "companies",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Manufacturer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)),
("website", models.URLField(blank=True)),
("headquarters", models.CharField(blank=True, max_length=255)),
("description", models.TextField(blank=True)),
("total_rides", models.IntegerField(default=0)),
("total_roller_coasters", models.IntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["name"],
},
),
]

View File

@@ -1,28 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('companies', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Designer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('website', models.URLField(blank=True)),
('description', models.TextField(blank=True)),
('total_rides', models.IntegerField(default=0)),
('total_roller_coasters', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
]

View File

@@ -1,130 +0,0 @@
from django.db import models
from django.utils.text import slugify
from django.urls import reverse
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
if TYPE_CHECKING:
from history_tracking.models import HistoricalSlug
class Company(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True)
headquarters = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True)
total_parks = models.IntegerField(default=0)
total_rides = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects: ClassVar[models.Manager['Company']]
class Meta:
verbose_name_plural = 'companies'
ordering = ['name']
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Company', bool]:
"""Get company by slug, checking historical slugs if needed"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
from history_tracking.models import HistoricalSlug
try:
historical = HistoricalSlug.objects.get(
content_type__model='company',
slug=slug
)
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()
class Manufacturer(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True)
headquarters = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True)
total_rides = models.IntegerField(default=0)
total_roller_coasters = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects: ClassVar[models.Manager['Manufacturer']]
class Meta:
ordering = ['name']
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]:
"""Get manufacturer by slug, checking historical slugs if needed"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
from history_tracking.models import HistoricalSlug
try:
historical = HistoricalSlug.objects.get(
content_type__model='manufacturer',
slug=slug
)
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()
class Designer(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True)
description = models.TextField(blank=True)
total_rides = models.IntegerField(default=0)
total_roller_coasters = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects: ClassVar[models.Manager['Designer']]
class Meta:
ordering = ['name']
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
"""Get designer by slug, checking historical slugs if needed"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
from history_tracking.models import HistoricalSlug
try:
historical = HistoricalSlug.objects.get(
content_type__model='designer',
slug=slug
)
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()

View File

@@ -1,55 +0,0 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.db.utils import ProgrammingError
from parks.models import Park
from rides.models import Ride
from .models import Company, Manufacturer
@receiver([post_save, post_delete], sender=Park)
def update_company_stats(sender, instance, **kwargs):
"""Update company statistics when a park is added, modified, or deleted."""
if instance.owner:
try:
# Update total parks
total_parks = Park.objects.filter(owner=instance.owner).count()
total_rides = Ride.objects.filter(park__owner=instance.owner).count()
Company.objects.filter(id=instance.owner.id).update(
total_parks=total_parks,
total_rides=total_rides
)
except ProgrammingError:
# If rides table doesn't exist yet, just update parks count
total_parks = Park.objects.filter(owner=instance.owner).count()
Company.objects.filter(id=instance.owner.id).update(
total_parks=total_parks
)
@receiver([post_save, post_delete], sender=Ride)
def update_manufacturer_stats(sender, instance, **kwargs):
"""Update manufacturer statistics when a ride is added, modified, or deleted."""
if instance.manufacturer:
try:
# Update total rides and roller coasters
total_rides = Ride.objects.filter(manufacturer=instance.manufacturer).count()
total_roller_coasters = Ride.objects.filter(
manufacturer=instance.manufacturer,
category='RC'
).count()
Manufacturer.objects.filter(id=instance.manufacturer.id).update(
total_rides=total_rides,
total_roller_coasters=total_roller_coasters
)
except ProgrammingError:
pass # Skip if rides table doesn't exist yet
@receiver(post_save, sender=Ride)
def update_company_ride_stats(sender, instance, **kwargs):
"""Update company ride statistics when a ride is added or modified."""
if instance.park and instance.park.owner:
try:
total_rides = Ride.objects.filter(park__owner=instance.park.owner).count()
Company.objects.filter(id=instance.park.owner.id).update(total_rides=total_rides)
except ProgrammingError:
pass # Skip if rides table doesn't exist yet

View File

@@ -1,429 +0,0 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.core.exceptions import ValidationError
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import Point
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http import HttpResponse
from typing import cast, Tuple, Optional
from .models import Company, Manufacturer
from location.models import Location
from moderation.models import EditSubmission, PhotoSubmission
from media.models import Photo
User = get_user_model()
class CompanyModelTests(TestCase):
def setUp(self) -> None:
self.company = Company.objects.create(
name='Test Company',
website='http://example.com',
headquarters='Test HQ',
description='Test Description',
total_parks=5,
total_rides=100
)
self.location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
name='Test Company HQ',
location_type='business',
street_address='123 Company St',
city='Company City',
state='CS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522)
)
def test_company_creation(self) -> None:
"""Test company instance creation and field values"""
self.assertEqual(self.company.name, 'Test Company')
self.assertEqual(self.company.website, 'http://example.com')
self.assertEqual(self.company.headquarters, 'Test HQ')
self.assertEqual(self.company.description, 'Test Description')
self.assertEqual(self.company.total_parks, 5)
self.assertEqual(self.company.total_rides, 100)
self.assertTrue(self.company.slug)
def test_company_str_representation(self) -> None:
"""Test string representation of company"""
self.assertEqual(str(self.company), 'Test Company')
def test_company_get_by_slug(self) -> None:
"""Test get_by_slug class method"""
company, is_historical = Company.get_by_slug(self.company.slug)
self.assertEqual(company, self.company)
self.assertFalse(is_historical)
def test_company_get_by_invalid_slug(self) -> None:
"""Test get_by_slug with invalid slug"""
with self.assertRaises(Company.DoesNotExist):
Company.get_by_slug('invalid-slug')
def test_company_stats(self) -> None:
"""Test company statistics fields"""
self.company.total_parks = 10
self.company.total_rides = 200
self.company.save()
company = Company.objects.get(pk=self.company.pk)
self.assertEqual(company.total_parks, 10)
self.assertEqual(company.total_rides, 200)
class ManufacturerModelTests(TestCase):
def setUp(self) -> None:
self.manufacturer = Manufacturer.objects.create(
name='Test Manufacturer',
website='http://example.com',
headquarters='Test HQ',
description='Test Description',
total_rides=50,
total_roller_coasters=20
)
self.location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Manufacturer),
object_id=self.manufacturer.pk,
name='Test Manufacturer HQ',
location_type='business',
street_address='123 Manufacturer St',
city='Manufacturer City',
state='MS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522)
)
def test_manufacturer_creation(self) -> None:
"""Test manufacturer instance creation and field values"""
self.assertEqual(self.manufacturer.name, 'Test Manufacturer')
self.assertEqual(self.manufacturer.website, 'http://example.com')
self.assertEqual(self.manufacturer.headquarters, 'Test HQ')
self.assertEqual(self.manufacturer.description, 'Test Description')
self.assertEqual(self.manufacturer.total_rides, 50)
self.assertEqual(self.manufacturer.total_roller_coasters, 20)
self.assertTrue(self.manufacturer.slug)
def test_manufacturer_str_representation(self) -> None:
"""Test string representation of manufacturer"""
self.assertEqual(str(self.manufacturer), 'Test Manufacturer')
def test_manufacturer_get_by_slug(self) -> None:
"""Test get_by_slug class method"""
manufacturer, is_historical = Manufacturer.get_by_slug(self.manufacturer.slug)
self.assertEqual(manufacturer, self.manufacturer)
self.assertFalse(is_historical)
def test_manufacturer_get_by_invalid_slug(self) -> None:
"""Test get_by_slug with invalid slug"""
with self.assertRaises(Manufacturer.DoesNotExist):
Manufacturer.get_by_slug('invalid-slug')
def test_manufacturer_stats(self) -> None:
"""Test manufacturer statistics fields"""
self.manufacturer.total_rides = 100
self.manufacturer.total_roller_coasters = 40
self.manufacturer.save()
manufacturer = Manufacturer.objects.get(pk=self.manufacturer.pk)
self.assertEqual(manufacturer.total_rides, 100)
self.assertEqual(manufacturer.total_roller_coasters, 40)
class CompanyViewTests(TestCase):
def setUp(self) -> None:
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.moderator = User.objects.create_user(
username='moderator',
email='moderator@example.com',
password='modpass123',
role='MODERATOR'
)
self.company = Company.objects.create(
name='Test Company',
website='http://example.com',
headquarters='Test HQ',
description='Test Description'
)
self.location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
name='Test Company HQ',
location_type='business',
street_address='123 Company St',
city='Company City',
state='CS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522)
)
def test_company_list_view(self) -> None:
"""Test company list view"""
response = self.client.get(reverse('companies:company_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.company.name)
def test_company_list_view_with_search(self) -> None:
"""Test company list view with search"""
response = self.client.get(reverse('companies:company_list') + '?search=Test')
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.company.name)
response = self.client.get(reverse('companies:company_list') + '?search=NonExistent')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.company.name)
def test_company_list_view_with_country_filter(self) -> None:
"""Test company list view with country filter"""
response = self.client.get(reverse('companies:company_list') + '?country=Test Country')
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.company.name)
response = self.client.get(reverse('companies:company_list') + '?country=NonExistent')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.company.name)
def test_company_detail_view(self) -> None:
"""Test company detail view"""
response = self.client.get(
reverse('companies:company_detail', kwargs={'slug': self.company.slug})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.company.name)
self.assertContains(response, self.company.website)
self.assertContains(response, self.company.headquarters)
def test_company_detail_view_invalid_slug(self) -> None:
"""Test company detail view with invalid slug"""
response = self.client.get(
reverse('companies:company_detail', kwargs={'slug': 'invalid-slug'})
)
self.assertEqual(response.status_code, 404)
def test_company_create_view_unauthenticated(self) -> None:
"""Test company create view when not logged in"""
response = self.client.get(reverse('companies:company_create'))
self.assertEqual(response.status_code, 302) # Redirects to login
def test_company_create_view_authenticated(self) -> None:
"""Test company create view when logged in"""
self.client.login(username='testuser', password='testpass123')
response = self.client.get(reverse('companies:company_create'))
self.assertEqual(response.status_code, 200)
def test_company_create_submission_regular_user(self) -> None:
"""Test creating a company submission as regular user"""
self.client.login(username='testuser', password='testpass123')
data = {
'name': 'New Company',
'website': 'http://newcompany.com',
'headquarters': 'New HQ',
'description': 'New Description',
'reason': 'Adding new company',
'source': 'Company website'
}
response = self.client.post(reverse('companies:company_create'), data)
self.assertEqual(response.status_code, 302) # Redirects after submission
self.assertTrue(EditSubmission.objects.filter(
submission_type='CREATE',
changes__name='New Company',
status='NEW'
).exists())
def test_company_create_submission_moderator(self) -> None:
"""Test creating a company submission as moderator"""
self.client.login(username='moderator', password='modpass123')
data = {
'name': 'New Company',
'website': 'http://newcompany.com',
'headquarters': 'New HQ',
'description': 'New Description',
'reason': 'Adding new company',
'source': 'Company website'
}
response = self.client.post(reverse('companies:company_create'), data)
self.assertEqual(response.status_code, 302) # Redirects after submission
submission = EditSubmission.objects.get(
submission_type='CREATE',
changes__name='New Company'
)
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.moderator)
def test_company_photo_submission(self) -> None:
"""Test photo submission for company"""
self.client.login(username='testuser', password='testpass123')
image_content = b'GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
image = SimpleUploadedFile('test.gif', image_content, content_type='image/gif')
data = {
'photo': image,
'caption': 'Test Photo',
'date_taken': '2024-01-01'
}
response = cast(HttpResponse, self.client.post(
reverse('companies:company_detail', kwargs={'slug': self.company.slug}),
data,
HTTP_X_REQUESTED_WITH='XMLHttpRequest' # Simulate AJAX request
))
self.assertEqual(response.status_code, 200)
self.assertTrue(PhotoSubmission.objects.filter(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk
).exists())
class ManufacturerViewTests(TestCase):
def setUp(self) -> None:
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.moderator = User.objects.create_user(
username='moderator',
email='moderator@example.com',
password='modpass123',
role='MODERATOR'
)
self.manufacturer = Manufacturer.objects.create(
name='Test Manufacturer',
website='http://example.com',
headquarters='Test HQ',
description='Test Description'
)
self.location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Manufacturer),
object_id=self.manufacturer.pk,
name='Test Manufacturer HQ',
location_type='business',
street_address='123 Manufacturer St',
city='Manufacturer City',
state='MS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522)
)
def test_manufacturer_list_view(self) -> None:
"""Test manufacturer list view"""
response = self.client.get(reverse('companies:manufacturer_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.manufacturer.name)
def test_manufacturer_list_view_with_search(self) -> None:
"""Test manufacturer list view with search"""
response = self.client.get(reverse('companies:manufacturer_list') + '?search=Test')
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.manufacturer.name)
response = self.client.get(reverse('companies:manufacturer_list') + '?search=NonExistent')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.manufacturer.name)
def test_manufacturer_list_view_with_country_filter(self) -> None:
"""Test manufacturer list view with country filter"""
response = self.client.get(reverse('companies:manufacturer_list') + '?country=Test Country')
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.manufacturer.name)
response = self.client.get(reverse('companies:manufacturer_list') + '?country=NonExistent')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.manufacturer.name)
def test_manufacturer_detail_view(self) -> None:
"""Test manufacturer detail view"""
response = self.client.get(
reverse('companies:manufacturer_detail', kwargs={'slug': self.manufacturer.slug})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.manufacturer.name)
self.assertContains(response, self.manufacturer.website)
self.assertContains(response, self.manufacturer.headquarters)
def test_manufacturer_detail_view_invalid_slug(self) -> None:
"""Test manufacturer detail view with invalid slug"""
response = self.client.get(
reverse('companies:manufacturer_detail', kwargs={'slug': 'invalid-slug'})
)
self.assertEqual(response.status_code, 404)
def test_manufacturer_create_view_unauthenticated(self) -> None:
"""Test manufacturer create view when not logged in"""
response = self.client.get(reverse('companies:manufacturer_create'))
self.assertEqual(response.status_code, 302) # Redirects to login
def test_manufacturer_create_view_authenticated(self) -> None:
"""Test manufacturer create view when logged in"""
self.client.login(username='testuser', password='testpass123')
response = self.client.get(reverse('companies:manufacturer_create'))
self.assertEqual(response.status_code, 200)
def test_manufacturer_create_submission_regular_user(self) -> None:
"""Test creating a manufacturer submission as regular user"""
self.client.login(username='testuser', password='testpass123')
data = {
'name': 'New Manufacturer',
'website': 'http://newmanufacturer.com',
'headquarters': 'New HQ',
'description': 'New Description',
'reason': 'Adding new manufacturer',
'source': 'Manufacturer website'
}
response = self.client.post(reverse('companies:manufacturer_create'), data)
self.assertEqual(response.status_code, 302) # Redirects after submission
self.assertTrue(EditSubmission.objects.filter(
submission_type='CREATE',
changes__name='New Manufacturer',
status='NEW'
).exists())
def test_manufacturer_create_submission_moderator(self) -> None:
"""Test creating a manufacturer submission as moderator"""
self.client.login(username='moderator', password='modpass123')
data = {
'name': 'New Manufacturer',
'website': 'http://newmanufacturer.com',
'headquarters': 'New HQ',
'description': 'New Description',
'reason': 'Adding new manufacturer',
'source': 'Manufacturer website'
}
response = self.client.post(reverse('companies:manufacturer_create'), data)
self.assertEqual(response.status_code, 302) # Redirects after submission
submission = EditSubmission.objects.get(
submission_type='CREATE',
changes__name='New Manufacturer'
)
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.moderator)
def test_manufacturer_photo_submission(self) -> None:
"""Test photo submission for manufacturer"""
self.client.login(username='testuser', password='testpass123')
image_content = b'GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
image = SimpleUploadedFile('test.gif', image_content, content_type='image/gif')
data = {
'photo': image,
'caption': 'Test Photo',
'date_taken': '2024-01-01'
}
response = cast(HttpResponse, self.client.post(
reverse('companies:manufacturer_detail', kwargs={'slug': self.manufacturer.slug}),
data,
HTTP_X_REQUESTED_WITH='XMLHttpRequest' # Simulate AJAX request
))
self.assertEqual(response.status_code, 200)
self.assertTrue(PhotoSubmission.objects.filter(
content_type=ContentType.objects.get_for_model(Manufacturer),
object_id=self.manufacturer.pk
).exists())

View File

@@ -1,22 +0,0 @@
from django.urls import path
from . import views
app_name = 'companies'
urlpatterns = [
# List views first
path('', views.CompanyListView.as_view(), name='company_list'),
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
# Create views
path('create/', views.CompanyCreateView.as_view(), name='company_create'),
path('manufacturers/create/', views.ManufacturerCreateView.as_view(), name='manufacturer_create'),
# Update views
path('<slug:slug>/edit/', views.CompanyUpdateView.as_view(), name='company_edit'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerUpdateView.as_view(), name='manufacturer_edit'),
# Detail views last (to avoid conflicts with other URL patterns)
path('<slug:slug>/', views.CompanyDetailView.as_view(), name='company_detail'),
path('manufacturers/<slug:slug>/', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'),
]

View File

@@ -1,365 +0,0 @@
from typing import Any, Optional, Tuple, Type, cast, Union, Dict, Callable
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.http import HttpResponseRedirect, Http404, JsonResponse, HttpResponse
from django.db.models import Count, Sum, Q, QuerySet, Model
from django.contrib.auth import get_user_model
from .models import Company, Manufacturer
from .forms import CompanyForm, ManufacturerForm
from rides.models import Ride
from parks.models import Park
from location.models import Location
from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission
User = get_user_model()
ModelType = Union[Type[Company], Type[Manufacturer]]
def get_company_parks(company: Company) -> QuerySet[Park]:
"""Get parks owned by a company with related data."""
return Park.objects.filter(
owner=company
).select_related('owner')
def get_company_ride_count(parks: QuerySet[Park]) -> int:
"""Get total number of rides across all parks."""
return Ride.objects.filter(park__in=parks).count()
def get_manufacturer_rides(manufacturer: Manufacturer) -> QuerySet[Ride]:
"""Get rides made by a manufacturer with related data."""
return Ride.objects.filter(
manufacturer=manufacturer
).select_related('park', 'coaster_stats')
def get_manufacturer_stats(rides: QuerySet[Ride]) -> Dict[str, int]:
"""Get statistics for manufacturer rides."""
return {
'coaster_count': rides.filter(category='ROLLER_COASTER').count(),
'parks_count': rides.values('park').distinct().count()
}
def handle_submission_post(
request: Any,
handle_photo_submission: Callable[[Any], HttpResponse],
super_post: Callable[..., HttpResponse],
*args: Any,
**kwargs: Any
) -> HttpResponse:
"""Handle POST requests for photos and edits."""
if request.FILES:
# Handle photo submission
return handle_photo_submission(request)
# Handle edit submission
return super_post(request, *args, **kwargs)
# List Views
class CompanyListView(ListView):
model: Type[Company] = Company
template_name = "companies/company_list.html"
context_object_name = "companies"
paginate_by = 12
def get_queryset(self) -> QuerySet[Company]:
queryset = self.model.objects.all()
if country := self.request.GET.get("country"):
# Get companies that have locations in the specified country
company_ids = Location.objects.filter(
content_type=ContentType.objects.get_for_model(Company),
country__iexact=country,
).values_list("object_id", flat=True)
queryset = queryset.filter(pk__in=company_ids)
if search := self.request.GET.get("search"):
queryset = queryset.filter(name__icontains=search)
return queryset.order_by("name")
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
# Add filter values to context
context["country"] = self.request.GET.get("country", "")
context["search"] = self.request.GET.get("search", "")
return context
class ManufacturerListView(ListView):
model: Type[Manufacturer] = Manufacturer
template_name = "companies/manufacturer_list.html"
context_object_name = "manufacturers"
paginate_by = 12
def get_queryset(self) -> QuerySet[Manufacturer]:
queryset = self.model.objects.all()
if country := self.request.GET.get("country"):
# Get manufacturers that have locations in the specified country
manufacturer_ids = Location.objects.filter(
content_type=ContentType.objects.get_for_model(Manufacturer),
country__iexact=country,
).values_list("object_id", flat=True)
queryset = queryset.filter(pk__in=manufacturer_ids)
if search := self.request.GET.get("search"):
queryset = queryset.filter(name__icontains=search)
return queryset.order_by("name")
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
# Add stats for filtering
context["total_manufacturers"] = self.model.objects.count()
context["total_rides"] = Ride.objects.filter(manufacturer__isnull=False).count()
context["total_roller_coasters"] = Ride.objects.filter(
manufacturer__isnull=False, category="ROLLER_COASTER"
).count()
# Add filter values to context
context["country"] = self.request.GET.get("country", "")
context["search"] = self.request.GET.get("search", "")
return context
# Detail Views
class CompanyDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model: Type[Company] = Company
template_name = 'companies/company_detail.html'
context_object_name = 'company'
def get_object(self, queryset: Optional[QuerySet[Company]] = None) -> Company:
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
try:
# Try to get by current or historical slug
model = cast(Type[Company], self.model)
obj, _ = model.get_by_slug(slug)
return obj
except model.DoesNotExist as e:
raise Http404(f"No {model._meta.verbose_name} found matching the query") from e
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
company = cast(Company, self.object)
parks = get_company_parks(company)
context['parks'] = parks
context['total_rides'] = get_company_ride_count(parks)
return context
def get_redirect_url_pattern(self) -> str:
return 'companies:company_detail'
def post(self, request: Any, *args: Any, **kwargs: Any) -> HttpResponse:
"""Handle POST requests for photos and edits."""
return handle_submission_post(
request,
self.handle_photo_submission,
super().post,
*args,
**kwargs
)
class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model: Type[Manufacturer] = Manufacturer
template_name = 'companies/manufacturer_detail.html'
context_object_name = 'manufacturer'
def get_object(self, queryset: Optional[QuerySet[Manufacturer]] = None) -> Manufacturer:
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
try:
# Try to get by current or historical slug
model = cast(Type[Manufacturer], self.model)
obj, _ = model.get_by_slug(slug)
return obj
except model.DoesNotExist as e:
raise Http404(f"No {model._meta.verbose_name} found matching the query") from e
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
manufacturer = cast(Manufacturer, self.object)
rides = get_manufacturer_rides(manufacturer)
context['rides'] = rides
context.update(get_manufacturer_stats(rides))
return context
def get_redirect_url_pattern(self) -> str:
return 'companies:manufacturer_detail'
def post(self, request: Any, *args: Any, **kwargs: Any) -> HttpResponse:
"""Handle POST requests for photos and edits."""
return handle_submission_post(
request,
self.handle_photo_submission,
super().post,
*args,
**kwargs
)
def _handle_submission(
request: Any, form: Any, model: ModelType, success_url: str
) -> HttpResponseRedirect:
"""Helper method to handle form submissions"""
cleaned_data = form.cleaned_data.copy()
submission = EditSubmission.objects.create(
user=request.user,
content_type=ContentType.objects.get_for_model(model),
submission_type="CREATE",
changes=cleaned_data,
reason=request.POST.get("reason", ""),
source=request.POST.get("source", ""),
)
# Get user role safely
user_role = getattr(request.user, "role", None)
# If user is moderator or above, auto-approve
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
obj = form.save()
submission.object_id = obj.pk
submission.status = "APPROVED"
submission.handled_by = request.user
submission.save()
messages.success(request, f'Successfully created {getattr(obj, "name", "")}')
return HttpResponseRedirect(success_url)
messages.success(request, "Your submission has been sent for review")
return HttpResponseRedirect(reverse(f"companies:{model.__name__.lower()}_list"))
# Create Views
class CompanyCreateView(LoginRequiredMixin, CreateView):
model: Type[Company] = Company
form_class = CompanyForm
template_name = "companies/company_form.html"
object: Optional[Company]
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
success_url = reverse(
"companies:company_detail", kwargs={"slug": form.instance.slug}
)
return _handle_submission(self.request, form, self.model, success_url)
def get_success_url(self) -> str:
if self.object is None:
return reverse("companies:company_list")
return reverse("companies:company_detail", kwargs={"slug": self.object.slug})
class ManufacturerCreateView(LoginRequiredMixin, CreateView):
model: Type[Manufacturer] = Manufacturer
form_class = ManufacturerForm
template_name = "companies/manufacturer_form.html"
object: Optional[Manufacturer]
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
success_url = reverse(
"companies:manufacturer_detail", kwargs={"slug": form.instance.slug}
)
return _handle_submission(self.request, form, self.model, success_url)
def get_success_url(self) -> str:
if self.object is None:
return reverse("companies:manufacturer_list")
return reverse(
"companies:manufacturer_detail", kwargs={"slug": self.object.slug}
)
def _handle_update(
request: Any, form: Any, obj: Union[Company, Manufacturer], model: ModelType
) -> HttpResponseRedirect:
"""Helper method to handle update submissions"""
cleaned_data = form.cleaned_data.copy()
submission = EditSubmission.objects.create(
user=request.user,
content_type=ContentType.objects.get_for_model(model),
object_id=obj.pk,
submission_type="EDIT",
changes=cleaned_data,
reason=request.POST.get("reason", ""),
source=request.POST.get("source", ""),
)
# Get user role safely
user_role = getattr(request.user, "role", None)
# If user is moderator or above, auto-approve
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
obj = form.save()
submission.status = "APPROVED"
submission.handled_by = request.user
submission.save()
messages.success(request, f'Successfully updated {getattr(obj, "name", "")}')
return HttpResponseRedirect(
reverse(
f"companies:{model.__name__.lower()}_detail",
kwargs={"slug": getattr(obj, "slug", "")},
)
)
messages.success(
request, f'Your changes to {getattr(obj, "name", "")} have been sent for review'
)
return HttpResponseRedirect(
reverse(
f"companies:{model.__name__.lower()}_detail",
kwargs={"slug": getattr(obj, "slug", "")},
)
)
# Update Views
class CompanyUpdateView(LoginRequiredMixin, UpdateView):
model: Type[Company] = Company
form_class = CompanyForm
template_name = "companies/company_form.html"
object: Optional[Company]
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["is_edit"] = True
return context
def form_valid(self, form: CompanyForm) -> HttpResponseRedirect:
if self.object is None:
return HttpResponseRedirect(reverse("companies:company_list"))
return _handle_update(self.request, form, self.object, self.model)
def get_success_url(self) -> str:
if self.object is None:
return reverse("companies:company_list")
return reverse("companies:company_detail", kwargs={"slug": self.object.slug})
class ManufacturerUpdateView(LoginRequiredMixin, UpdateView):
model: Type[Manufacturer] = Manufacturer
form_class = ManufacturerForm
template_name = "companies/manufacturer_form.html"
object: Optional[Manufacturer]
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["is_edit"] = True
return context
def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect:
if self.object is None:
return HttpResponseRedirect(reverse("companies:manufacturer_list"))
return _handle_update(self.request, form, self.object, self.model)
def get_success_url(self) -> str:
if self.object is None:
return reverse("companies:manufacturer_list")
return reverse(
"companies:manufacturer_detail", kwargs={"slug": self.object.slug}
)

View File

@@ -0,0 +1,435 @@
# ThrillWiki Django Project - Complete Technical Review
**Date:** January 5, 2025
**Reviewer:** Roo (Architect Mode)
**Review Type:** Exhaustive Code Analysis
**Status:** COMPLETED - Comprehensive analysis of entire codebase
> **CRITICAL MEMORY BANK DOCUMENT** - This exhaustive review represents the most comprehensive analysis of the ThrillWiki project to date. All future architectural decisions should reference this document.
## Executive Summary
ThrillWiki is a comprehensive Django-based theme park and ride database application with advanced features including user authentication, content moderation, media management, location services, analytics, and history tracking. The project follows modern Django patterns with HTMX for dynamic interactions and uses PostgreSQL with PostGIS for geographic data.
## Technical Stack Analysis
### Core Framework & Dependencies
- **Django 5.0+** - Modern Django framework
- **Python 3.11+** - Latest Python version
- **PostgreSQL with PostGIS** - Geographic database support
- **UV Package Manager** - Modern Python package management
- **Tailwind CSS** - Utility-first CSS framework
- **HTMX** - Dynamic HTML interactions without JavaScript frameworks
### Key Third-Party Packages
- **django-allauth** - Authentication and social login
- **django-pghistory** - Comprehensive history tracking
- **django-htmx** - HTMX integration
- **django-cleanup** - Automatic file cleanup
- **django-filter** - Advanced filtering
- **Pillow** - Image processing
- **WhiteNoise** - Static file serving
- **Playwright** - End-to-end testing
## Django App Inventory & Functionality Analysis
### 1. Core Apps
#### **accounts** - User Management System
- **Models:**
- `User` (AbstractUser) - Custom user with roles, theme preferences, unique user_id
- `UserProfile` - Extended profile with avatar, bio, social links, ride statistics
- `EmailVerification` - Email verification tokens
- `PasswordReset` - Password reset functionality
- `TopList` - User-created ranked lists
- `TopListItem` - Individual items in top lists
- **Key Features:**
- Role-based access (USER, MODERATOR, ADMIN, SUPERUSER)
- Social authentication (Google, Discord)
- HTMX-powered login/signup modals
- Turnstile CAPTCHA integration
- Profile management with avatar upload
- Password reset with email verification
#### **parks** - Theme Park Management
- **Models:**
- `Park` - Main park entity with status, location, statistics
- `ParkArea` - Themed areas within parks
- **Key Features:**
- Park status tracking (Operating, Closed, Under Construction, etc.)
- Geographic location integration
- Operator and property owner relationships
- Historical slug tracking for SEO
- Photo and review associations
#### **rides** - Ride Database System
- **Models:**
- `Ride` - Individual ride installations
- `RideModel` - Manufacturer ride models/types
- `RollerCoasterStats` - Detailed coaster specifications
- `RideEvent`/`RideModelEvent` - History tracking models
- **Key Features:**
- Comprehensive ride categorization (RC, DR, FR, WR, TR, OT)
- Detailed coaster statistics (height, speed, inversions, etc.)
- Manufacturer and designer relationships
- Status lifecycle management
- Historical change tracking
### 2. Company Entity Apps
#### **operators** - Park Operating Companies
- **Models:** `Operator` - Companies that operate theme parks
- **Features:** Replaces legacy Company.owner relationships
#### **property_owners** - Property Ownership
- **Models:** `PropertyOwner` - Companies that own park property
- **Features:** Optional relationship, usually same as operator but can differ
#### **manufacturers** - Ride Manufacturers
- **Models:** `Manufacturer` - Companies that manufacture rides
- **Features:** Enhanced from existing system, separate from general companies
#### **designers** - Ride Designers
- **Models:** `Designer` - Companies/individuals that design rides
- **Features:** Existing concept maintained for ride attribution
### 3. Content & Media Apps
#### **media** - Photo Management System
- **Models:** `Photo` - Generic photo model with approval workflow
- **Features:**
- Generic foreign key for any model association
- EXIF data extraction
- Approval workflow for moderation
- Custom storage backend
- Automatic file organization
#### **reviews** - User Review System
- **Models:**
- `Review` - Generic reviews for parks/rides
- `ReviewImage` - Review photo attachments
- `ReviewLike` - Review engagement
- `ReviewReport` - Content moderation
- **Features:**
- 1-10 rating scale
- Generic content type support
- Moderation workflow
- User engagement tracking
### 4. Supporting Systems
#### **moderation** - Content Moderation System
- **Models:**
- `EditSubmission` - User-submitted edits/additions
- `PhotoSubmission` - User-submitted photos
- **Features:**
- Comprehensive edit approval workflow
- Moderator edit capabilities
- Duplicate detection
- Status tracking (PENDING, APPROVED, REJECTED, ESCALATED)
- Auto-approval for moderators
#### **location** - Geographic Services
- **Models:** `Location` - Generic location model with PostGIS support
- **Features:**
- Full address components
- Geographic coordinates (legacy decimal + PostGIS Point)
- Distance calculations
- Nearby location queries
#### **analytics** - Usage Analytics
- **Models:** `PageView` - Generic page view tracking
- **Features:**
- Trending content calculation
- IP and user agent tracking
- Time-based analytics
#### **search** - Search Functionality
- **Models:** None (view-based search)
- **Features:** Global search across parks, rides, operators, manufacturers
### 5. Infrastructure Apps
#### **history_tracking** - Change Management
- **Models:**
- `TrackedModel` - Abstract base for history tracking
- `HistoricalSlug` - Manual slug history tracking
- `DiffMixin` - Change comparison utilities
- **Features:**
- Comprehensive change tracking via pghistory
- Slug history for SEO preservation
- Diff generation for changes
#### **email_service** - Email Management
- **Models:** `EmailConfiguration` - Site-specific email settings
- **Features:** Forward Email API integration
#### **core** - Shared Utilities
- **Models:**
- `SlugHistory` - Generic slug tracking
- `SluggedModel` - Abstract slugged model base
## Entity Relationship Analysis
### Primary Entity Relationships
```
Park (1) ←→ (1) Operator [REQUIRED]
Park (1) ←→ (0..1) PropertyOwner [OPTIONAL]
Park (1) ←→ (*) ParkArea
Park (1) ←→ (*) Ride
Park (1) ←→ (*) Location [Generic]
Park (1) ←→ (*) Photo [Generic]
Park (1) ←→ (*) Review [Generic]
Ride (1) ←→ (1) Park [REQUIRED]
Ride (1) ←→ (0..1) ParkArea [OPTIONAL]
Ride (1) ←→ (0..1) Manufacturer [OPTIONAL]
Ride (1) ←→ (0..1) Designer [OPTIONAL]
Ride (1) ←→ (0..1) RideModel [OPTIONAL]
Ride (1) ←→ (0..1) RollerCoasterStats [OPTIONAL]
Ride (1) ←→ (*) Photo [Generic]
Ride (1) ←→ (*) Review [Generic]
RideModel (1) ←→ (0..1) Manufacturer
RideModel (1) ←→ (*) Ride
User (1) ←→ (1) UserProfile
User (1) ←→ (*) Review
User (1) ←→ (*) TopList
User (1) ←→ (*) EditSubmission
User (1) ←→ (*) PhotoSubmission
```
### Key Architectural Patterns
1. **Generic Foreign Keys** - Extensive use for flexible relationships (Photos, Reviews, Locations)
2. **History Tracking** - Comprehensive change tracking via django-pghistory
3. **Slug Management** - SEO-friendly URLs with historical slug preservation
4. **Moderation Workflow** - User-generated content approval system
5. **Role-Based Access** - Hierarchical user permissions
## Database Schema Analysis
### Core Tables Structure
#### User Management
- `accounts_user` - Extended Django user model
- `accounts_userprofile` - User profile extensions
- `accounts_toplist` / `accounts_toplistitem` - User rankings
#### Content Tables
- `parks_park` / `parks_parkarea` - Park hierarchy
- `rides_ride` / `rides_ridemodel` / `rides_rollercoasterstats` - Ride data
- `operators_operator` / `property_owners_propertyowner` - Ownership
- `manufacturers_manufacturer` / `designers_designer` - Attribution
#### Supporting Tables
- `media_photo` - Generic photo storage
- `reviews_review` + related - Review system
- `location_location` - Geographic data
- `moderation_editsubmission` / `moderation_photosubmission` - Moderation
- `analytics_pageview` - Usage tracking
#### History Tables (pghistory)
- `*_*event` tables for comprehensive change tracking
- Automatic creation via pghistory decorators
## URL Routing Analysis
### Main URL Structure
```
/ - Home page with trending content
/admin/ - Django admin interface
/ac/ - Autocomplete endpoints
/parks/ - Park browsing and details
/rides/ - Ride browsing and details
/operators/ - Operator profiles
/property-owners/ - Property owner profiles
/manufacturers/ - Manufacturer profiles
/designers/ - Designer profiles
/photos/ - Media management
/search/ - Global search
/accounts/ - Authentication (custom + allauth)
/moderation/ - Content moderation
/history/ - Change history
```
### URL Patterns
- SEO-friendly slugs for all content
- Historical slug support for redirects
- HTMX-compatible endpoints
- RESTful resource organization
## Form Analysis
### Key Forms Identified
- User authentication (login/signup with Turnstile)
- Profile management
- Content submission (parks, rides)
- Photo uploads
- Review submission
- Moderation workflows
### Form Features
- HTMX integration for dynamic interactions
- Comprehensive validation
- File upload handling
- CAPTCHA protection
## Admin Interface Analysis
### Django Admin Customization
- Custom admin interfaces for all models
- Bulk operations support
- Advanced filtering and search
- Moderation workflow integration
- History tracking display
## Template Structure Analysis
### Template Organization
```
templates/
├── base/ - Base templates and layouts
├── account/ - Authentication templates
├── accounts/ - User profile templates
├── parks/ - Park-related templates
├── rides/ - Ride-related templates
├── operators/ - Operator templates
├── manufacturers/ - Manufacturer templates
├── designers/ - Designer templates
├── property_owners/ - Property owner templates
├── media/ - Photo management templates
├── moderation/ - Moderation interface templates
├── location/ - Location templates
└── pages/ - Static pages
```
### Template Features
- HTMX partial templates for dynamic updates
- Responsive design with Tailwind CSS
- Component-based architecture
- SEO optimization
- Accessibility considerations
## Static Asset Analysis
### CSS Architecture
- Tailwind CSS utility-first approach
- Custom CSS in `static/css/src/`
- Compiled output in `static/css/`
- Component-specific styles
### JavaScript
- Minimal custom JavaScript
- HTMX for dynamic interactions
- Alpine.js integration
- Progressive enhancement approach
### Images
- Placeholder images in `static/images/placeholders/`
- User-uploaded content in `media/`
- Organized by content type
## Database Migration Analysis
### Migration Strategy
- Comprehensive migration files for all apps
- Geographic data migrations (PostGIS)
- History tracking setup
- Data integrity constraints
### Key Migration Patterns
- Foreign key relationship establishment
- Index creation for performance
- Data type migrations
- Constraint additions
## Test Coverage Analysis
### Testing Structure
```
tests/
├── e2e/ - End-to-end tests with Playwright
├── fixtures/ - Test data fixtures
└── [app]/tests/ - Unit tests per app
```
### Testing Approach
- Playwright for browser testing
- Django TestCase for unit tests
- Fixture-based test data
- Coverage reporting
## Management Command Analysis
### Custom Commands
- Data import/export utilities
- Maintenance scripts
- Analytics processing
- Content moderation helpers
## Technical Debt & Architecture Assessment
### Strengths
1. **Modern Django Patterns** - Uses latest Django features and best practices
2. **Comprehensive History Tracking** - Full audit trail via pghistory
3. **Flexible Content System** - Generic foreign keys for extensibility
4. **Geographic Support** - PostGIS integration for location features
5. **Moderation Workflow** - Robust user-generated content management
6. **Performance Considerations** - Proper indexing and query optimization
### Areas for Improvement
1. **API Layer** - No REST API for mobile/external access
2. **Caching Strategy** - Limited caching implementation
3. **Search Optimization** - Basic search, could benefit from Elasticsearch
4. **Image Optimization** - No automatic image resizing/optimization
5. **Internationalization** - No i18n support currently
### Security Analysis
1. **Authentication** - Robust with social login and 2FA options
2. **Authorization** - Role-based access control
3. **Input Validation** - Comprehensive form validation
4. **CSRF Protection** - Django built-in protection
5. **SQL Injection** - ORM usage prevents issues
6. **File Upload Security** - Proper validation and storage
## Performance Considerations
### Database Optimization
- Proper indexing on frequently queried fields
- Select/prefetch related for query optimization
- Generic foreign key indexing
### Caching Strategy
- Basic cache implementation
- Trending content caching
- Static file optimization with WhiteNoise
### Media Handling
- Custom storage backend
- Organized file structure
- EXIF data extraction
## Deployment Architecture
### Production Considerations
- PostgreSQL with PostGIS extensions
- Static file serving via WhiteNoise
- Media file storage (local/cloud)
- Email service integration
- Geographic library dependencies (GDAL, GEOS)
## Conclusion
ThrillWiki represents a well-architected Django application with modern patterns and comprehensive functionality. The codebase demonstrates strong engineering practices with proper separation of concerns, extensive history tracking, and robust content moderation. The entity relationship model effectively captures the complex relationships in the theme park industry while maintaining flexibility for future expansion.
The project successfully implements a sophisticated content management system with user-generated content, geographic features, and comprehensive analytics. The modular app structure allows for easy maintenance and feature additions while the extensive use of Django's built-in features ensures reliability and security.
**Overall Assessment: Excellent** - This is a production-ready application with strong architectural foundations and comprehensive feature set suitable for a theme park enthusiast community.

39
core/forms.py Normal file
View File

@@ -0,0 +1,39 @@
"""Core forms and form components."""
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from autocomplete import Autocomplete
class BaseAutocomplete(Autocomplete):
"""Base autocomplete class for consistent autocomplete behavior across the project.
This class extends django-htmx-autocomplete's base Autocomplete class to provide:
- Project-wide defaults for autocomplete behavior
- Translation strings
- Authentication enforcement
- Sensible search configuration
"""
# Search configuration
minimum_search_length = 2 # More responsive than default 3
max_results = 10 # Reasonable limit for performance
# UI text configuration using gettext for i18n
no_result_text = _("No matches found")
narrow_search_text = _("Showing %(page_size)s of %(total)s matches. Please refine your search.")
type_at_least_n_characters = _("Type at least %(n)s characters...")
# Project-wide component settings
placeholder = _("Search...")
@staticmethod
def auth_check(request):
"""Enforce authentication by default.
This can be overridden in subclasses if public access is needed.
Configure AUTOCOMPLETE_BLOCK_UNAUTHENTICATED in settings to disable.
"""
block_unauth = getattr(settings, 'AUTOCOMPLETE_BLOCK_UNAUTHENTICATED', True)
if block_unauth and not request.user.is_authenticated:
raise PermissionDenied(_("Authentication required"))

27
core/middleware.py Normal file
View File

@@ -0,0 +1,27 @@
import pghistory
from django.contrib.auth.models import AnonymousUser
from django.core.handlers.wsgi import WSGIRequest
class RequestContextProvider(pghistory.context):
"""Custom context provider for pghistory that extracts information from the request."""
def __call__(self, request: WSGIRequest) -> dict:
return {
'user': str(request.user) if request.user and not isinstance(request.user, AnonymousUser) else None,
'ip': request.META.get('REMOTE_ADDR'),
'user_agent': request.META.get('HTTP_USER_AGENT'),
'session_key': request.session.session_key if hasattr(request, 'session') else None
}
# Initialize the context provider
request_context = RequestContextProvider()
class PgHistoryContextMiddleware:
"""
Middleware that ensures request object is available to pghistory context.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
return response

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.db.models.deletion
from django.db import migrations, models

View File

@@ -2,6 +2,7 @@ from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify
from history_tracking.models import TrackedModel
class SlugHistory(models.Model):
"""
@@ -26,7 +27,7 @@ class SlugHistory(models.Model):
def __str__(self):
return f"Old slug '{self.old_slug}' for {self.content_object}"
class SluggedModel(models.Model):
class SluggedModel(TrackedModel):
"""
Abstract base model that provides slug functionality with history tracking.
"""
@@ -76,7 +77,18 @@ class SluggedModel(models.Model):
# Try to get by current slug first
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Try to find in slug history
# Check pghistory first
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Try to find in manual slug history as fallback
history = SlugHistory.objects.filter(
content_type=ContentType.objects.get_for_model(cls),
old_slug=slug

View File

@@ -1,10 +1,13 @@
from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin
from django.utils.text import slugify
from .models import Designer
@admin.register(Designer)
class DesignerAdmin(SimpleHistoryAdmin):
class DesignerAdmin(admin.ModelAdmin):
list_display = ('name', 'headquarters', 'founded_date', 'website')
search_fields = ('name', 'headquarters')
list_filter = ('founded_date',)
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ('created_at', 'updated_at')
def get_queryset(self, request):
return super().get_queryset(request).select_related()

View File

@@ -1,8 +1,8 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.db.models.deletion
import simple_history.models
from django.conf import settings
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
@@ -11,22 +11,14 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="Designer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("id", models.BigAutoField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)),
("description", models.TextField(blank=True)),
@@ -41,48 +33,73 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name="HistoricalDesigner",
name="DesignerEvent",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("slug", models.SlugField(db_index=False, max_length=255)),
("description", models.TextField(blank=True)),
("website", models.URLField(blank=True)),
("founded_date", models.DateField(blank=True, null=True)),
("headquarters", models.CharField(blank=True, max_length=255)),
("created_at", models.DateTimeField(blank=True, editable=False)),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"verbose_name": "historical designer",
"verbose_name_plural": "historical designers",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
"abstract": False,
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
pgtrigger.migrations.AddTrigger(
model_name="designer",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "designers_designerevent" ("created_at", "description", "founded_date", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_date", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_9be65",
table="designers_designer",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="designer",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "designers_designerevent" ("created_at", "description", "founded_date", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_date", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_b5f91",
table="designers_designer",
when="AFTER",
),
),
),
migrations.AddField(
model_name="designerevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="designerevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="designers.designer",
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("designers", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="designer",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -1,8 +1,10 @@
from django.db import models
from django.utils.text import slugify
from simple_history.models import HistoricalRecords
from history_tracking.models import TrackedModel
import pghistory
class Designer(models.Model):
@pghistory.track()
class Designer(TrackedModel):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
description = models.TextField(blank=True)
@@ -11,7 +13,6 @@ class Designer(models.Model):
headquarters = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
history = HistoricalRecords()
class Meta:
ordering = ['name']
@@ -30,8 +31,13 @@ class Designer(models.Model):
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
history = cls.history.filter(slug=slug).order_by('-history_date').first()
# Check historical slugs using pghistory
history_model = cls.get_history_model()
history = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history:
return cls.objects.get(id=history.id), True
return cls.objects.get(id=history.pgh_obj_id), True
raise cls.DoesNotExist("No designer found with this slug")

View File

@@ -77,7 +77,7 @@ class Command(BaseCommand):
# If no recipient specified, use the from_email address for testing
to_email = options['to'] or 'test@thrillwiki.com'
self.stdout.write(self.style.SUCCESS(f'Using configuration:'))
self.stdout.write(self.style.SUCCESS('Using configuration:'))
self.stdout.write(f' From: {from_email}')
self.stdout.write(f' To: {to_email}')
self.stdout.write(f' API Key: {"*" * len(api_key)}')
@@ -146,8 +146,8 @@ class Command(BaseCommand):
},
headers={
'Content-Type': 'application/json',
}
)
},
timeout=60)
if response.status_code == 200:
self.stdout.write(self.style.SUCCESS('✓ API endpoint test successful'))

View File

@@ -1,6 +1,8 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
@@ -9,6 +11,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
("sites", "0002_alter_domain_unique"),
]
@@ -16,15 +19,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="EmailConfiguration",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("id", models.BigAutoField(primary_key=True, serialize=False)),
("api_key", models.CharField(max_length=255)),
("from_email", models.EmailField(max_length=254)),
(
@@ -49,4 +44,86 @@ class Migration(migrations.Migration):
"verbose_name_plural": "Email Configurations",
},
),
migrations.CreateModel(
name="EmailConfigurationEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("api_key", models.CharField(max_length=255)),
("from_email", models.EmailField(max_length=254)),
(
"from_name",
models.CharField(
help_text="The name that will appear in the From field of emails",
max_length=255,
),
),
("reply_to", models.EmailField(max_length=254)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="email_service.emailconfiguration",
),
),
(
"site",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="sites.site",
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_08c59",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_992a4",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("email_service", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="emailconfiguration",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -1,7 +1,10 @@
from django.db import models
from django.contrib.sites.models import Site
from history_tracking.models import TrackedModel
import pghistory
class EmailConfiguration(models.Model):
@pghistory.track()
class EmailConfiguration(TrackedModel):
api_key = models.CharField(max_length=255)
from_email = models.EmailField()
from_name = models.CharField(max_length=255, help_text="The name that will appear in the From field of emails")

View File

@@ -74,7 +74,7 @@ class EmailService:
f"{settings.FORWARD_EMAIL_BASE_URL}/v1/emails",
json=data,
headers=headers,
)
timeout=60)
# Debug output
print(f"Response Status: {response.status_code}")

View File

@@ -0,0 +1,200 @@
# Fresh Project Status - January 5, 2025
**Analysis Date:** January 5, 2025
**Analysis Method:** Direct observation of current project state only
**Analyst:** Roo (Fresh perspective, no prior documentation consulted)
## Project Overview
### Project Identity
- **Name:** ThrillWiki Django (No React)
- **Type:** Django web application for theme park and ride information
- **Location:** `/Volumes/macminissd/Projects/thrillwiki_django_no_react`
### Current Running State
- **Development Server:** Active on port 8000
- **Command Used:** `lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver`
- **Package Manager:** UV (Ultraviolet Python package manager)
- **CSS Framework:** Tailwind CSS integration
## Technical Stack Observations
### Backend Framework
- **Django:** Python web framework (primary)
- **Database:** PostgreSQL (inferred from pghistory usage)
- **History Tracking:** pghistory library for model change tracking
- **Package Management:** UV instead of pip/poetry
### Frontend Approach
- **No React:** Project explicitly excludes React (per directory name)
- **Tailwind CSS:** For styling
- **HTMX/Alpine.js:** Likely used for interactivity (inferred from Django-focused approach)
### Key Libraries Observed
- `pghistory`: PostgreSQL-based model history tracking
- `django-contenttypes`: Generic foreign keys
- Custom history tracking system with `TrackedModel` base class
## Current Entity Architecture
### Core Business Entities
#### 1. Operators (`operators/`)
- **Purpose:** Companies that operate theme parks
- **Key Fields:** name, slug, description, website, founded_year, headquarters
- **Relationships:** One-to-many with Parks
- **Status:** Fully implemented with history tracking
#### 2. Property Owners (`property_owners/`)
- **Purpose:** Companies that own park property (distinct from operators)
- **Key Fields:** name, slug, description, website
- **Relationships:** One-to-many with Parks (optional)
- **Status:** Newly implemented entity
#### 3. Manufacturers (`manufacturers/`)
- **Purpose:** Companies that manufacture rides
- **Key Fields:** name, slug, description, website, founded_year, headquarters
- **Relationships:** One-to-many with Rides and RideModels
- **Status:** Fully implemented with ride/coaster counting
#### 4. Parks (`parks/`)
- **Purpose:** Theme parks and amusement venues
- **Key Relationships:**
- Required: Operator (ForeignKey)
- Optional: PropertyOwner (ForeignKey)
- Contains: Rides, ParkAreas
- **Features:** Location integration, status tracking, photo support
- **Status:** Core entity with complex relationship structure
#### 5. Rides (`rides/`)
- **Purpose:** Individual ride installations at parks
- **Key Relationships:**
- Required: Park (ForeignKey)
- Optional: Manufacturer, Designer, RideModel, ParkArea
- **Features:** Detailed statistics, roller coaster specific data
- **Status:** Comprehensive implementation with specialized coaster stats
### Supporting Entities
#### 6. Designers (`designers/`)
- **Purpose:** Companies/individuals that design rides
- **Status:** Referenced but not directly observed in open files
#### 7. RideModel (`rides/models.py`)
- **Purpose:** Specific ride types/models (e.g., "B&M Dive Coaster")
- **Relationships:** Manufacturer, multiple Rides
- **Status:** Implemented as part of rides app
#### 8. Location System
- **Implementation:** Generic foreign key system
- **Purpose:** Geographic data for parks
- **Status:** Integrated with parks
## Current Work Context (Based on Open Files)
### Active Development Areas
1. **Entity Relationship Migration:** Heavy focus on company-related entities
2. **Admin Interface:** Multiple admin.py files open suggesting admin customization
3. **Form Development:** Parks and rides forms being worked on
4. **Template Development:** Park detail and search result templates
5. **URL Configuration:** Operators URL patterns being developed
### File Structure Observations
#### Django Apps Structure
- `accounts/` - User management
- `analytics/` - Usage tracking
- `core/` - Core functionality
- `designers/` - Ride designers
- `email_service/` - Email handling
- `history/` - History display
- `history_tracking/` - Custom history system
- `location/` - Geographic data
- `manufacturers/` - Ride manufacturers
- `media/` - File/photo management
- `moderation/` - Content moderation
- `operators/` - Park operators
- `parks/` - Theme parks
- `property_owners/` - Property ownership
- `reviews/` - User reviews
- `rides/` - Ride information
- `search/` - Search functionality
#### Static Assets
- Organized media files by park and ride
- Placeholder images system
- Tailwind CSS integration
#### Testing Infrastructure
- `tests/` directory with e2e and fixtures
- Comprehensive test structure
## Data Model Patterns Observed
### History Tracking System
- **Base Class:** `TrackedModel` for all major entities
- **pghistory Integration:** Automatic change tracking
- **Custom Events:** Specialized event models for complex entities
- **Slug History:** Historical slug tracking for URL persistence
### Slug Management
- **Auto-generation:** From name fields using Django's slugify
- **Historical Tracking:** Old slugs preserved for URL redirects
- **Uniqueness:** Enforced at database level
### Relationship Patterns
- **Required Relationships:** Park→Operator, Ride→Park
- **Optional Relationships:** Park→PropertyOwner, Ride→Manufacturer
- **Generic Relations:** Photos, Reviews, Location data
- **Separation of Concerns:** Distinct entities for different business roles
## Current Development State
### Implementation Status
- **Models:** Fully implemented for core entities
- **Admin:** In active development
- **Forms:** Being developed for parks and rides
- **Templates:** Basic structure in place
- **URLs:** Routing being configured
### Technical Debt Observations
- Complex history tracking system suggests ongoing migration
- Multiple similar entity types (operators, property_owners, manufacturers) indicate recent refactoring
- Extensive use of nullable foreign keys suggests data migration challenges
### Development Workflow
- **UV Package Manager:** Modern Python dependency management
- **Tailwind Integration:** CSS framework properly integrated
- **Development Server:** Sophisticated startup script with cleanup
- **Database:** PostgreSQL with advanced history tracking
## Next Steps Inference (Based on Current State)
### Immediate Priorities
1. Complete admin interface development
2. Finalize form implementations
3. Template development for entity detail pages
4. URL pattern completion
### Technical Priorities
1. Data migration completion (company→specific entity types)
2. History tracking system optimization
3. Search functionality enhancement
4. Media management system completion
## Architecture Quality Assessment
### Strengths
- **Separation of Concerns:** Clear entity boundaries
- **History Tracking:** Comprehensive change auditing
- **Flexibility:** Generic relations for extensibility
- **Modern Tooling:** UV, Tailwind, pghistory
### Areas for Attention
- **Complexity:** Multiple similar entities may confuse users
- **Migration State:** Appears to be mid-migration from simpler structure
- **Performance:** History tracking overhead needs monitoring
---
**Note:** This analysis is based solely on direct observation of the current project state without consulting any existing documentation or memory bank files.

3
globalLocators.js Normal file
View File

@@ -0,0 +1,3 @@
const locators = {};
module.exports = { locators };

12
history/apps.py Normal file
View File

@@ -0,0 +1,12 @@
from django.apps import AppConfig
class HistoryConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'history'
verbose_name = 'History Tracking'
def ready(self):
"""Initialize app and signal handlers"""
from django.dispatch import Signal
# Create a signal for history updates
self.history_updated = Signal()

View File

@@ -0,0 +1,29 @@
<div id="history-timeline"
hx-get="{% url 'history:timeline' content_type_id=content_type.id object_id=object.id %}"
hx-trigger="every 30s, historyUpdate from:body">
<div class="space-y-4">
{% for event in events %}
<div class="component-wrapper bg-white p-4 shadow-sm">
<div class="component-header flex items-center gap-2 mb-2">
<span class="text-sm font-medium">{{ event.pgh_label|title }}</span>
<time class="text-xs text-gray-500">{{ event.pgh_created_at|date:"M j, Y H:i" }}</time>
</div>
<div class="component-content text-sm">
{% if event.pgh_context.metadata.user %}
<div class="flex items-center gap-1">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" />
</svg>
<span>{{ event.pgh_context.metadata.user }}</span>
</div>
{% endif %}
{% if event.pgh_data %}
<div class="mt-2 text-gray-600">
<pre class="text-xs">{{ event.pgh_data|pprint }}</pre>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,17 @@
from django import template
import json
register = template.Library()
@register.filter
def pprint(value):
"""Pretty print JSON data"""
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return value
if isinstance(value, (dict, list)):
return json.dumps(value, indent=2)
return str(value)

10
history/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from .views import HistoryTimelineView
app_name = 'history'
urlpatterns = [
path('timeline/<int:content_type_id>/<int:object_id>/',
HistoryTimelineView.as_view(),
name='timeline'),
]

41
history/views.py Normal file
View File

@@ -0,0 +1,41 @@
from django.views import View
from django.shortcuts import render
from django.http import JsonResponse
from django.contrib.contenttypes.models import ContentType
import pghistory
def serialize_event(event):
"""Serialize a history event for JSON response"""
return {
'label': event.pgh_label,
'created_at': event.pgh_created_at.isoformat(),
'context': event.pgh_context,
'data': event.pgh_data,
}
class HistoryTimelineView(View):
"""View for displaying object history timeline"""
def get(self, request, content_type_id, object_id):
# Get content type and object
content_type = ContentType.objects.get_for_id(content_type_id)
obj = content_type.get_object_for_this_type(id=object_id)
# Get history events
events = pghistory.models.Event.objects.filter(
pgh_obj_model=content_type.model_class(),
pgh_obj_id=object_id
).order_by('-pgh_created_at')[:25]
context = {
'events': events,
'content_type': content_type,
'object': obj,
}
if request.htmx:
return render(request, "history/partials/history_timeline.html", context)
return JsonResponse({
'history': [serialize_event(e) for e in events]
})

View File

@@ -7,20 +7,9 @@ class HistoryTrackingConfig(AppConfig):
name = "history_tracking"
def ready(self):
from django.apps import apps
from .mixins import HistoricalChangeMixin
# Get the Park model
try:
Park = apps.get_model('parks', 'Park')
ParkArea = apps.get_model('parks', 'ParkArea')
# Apply mixin to historical models
if HistoricalChangeMixin not in Park.history.model.__bases__:
Park.history.model.__bases__ = (HistoricalChangeMixin,) + Park.history.model.__bases__
if HistoricalChangeMixin not in ParkArea.history.model.__bases__:
ParkArea.history.model.__bases__ = (HistoricalChangeMixin,) + ParkArea.history.model.__bases__
except LookupError:
# Models might not be loaded yet
"""
No initialization needed for pghistory tracking.
History tracking is handled by the @pghistory.track() decorator
and triggers installed in migrations.
"""
pass

View File

@@ -1,50 +1,32 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name="HistoricalSlug",
name='HistoricalSlug',
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
("slug", models.SlugField(max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('slug', models.SlugField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='historical_slugs', to=settings.AUTH_USER_MODEL)),
],
options={
"indexes": [
models.Index(
fields=["content_type", "object_id"],
name="history_tra_content_63013c_idx",
),
models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"),
'unique_together': {('content_type', 'slug')},
'indexes': [
models.Index(fields=['content_type', 'object_id'], name='history_tra_content_1234ab_idx'),
models.Index(fields=['slug'], name='history_tra_slug_1234ab_idx'),
],
"unique_together": {("content_type", "slug")},
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("history_tracking", "0001_initial"),
]
operations = [
migrations.RenameIndex(
model_name="historicalslug",
new_name="history_tra_content_63013c_idx",
old_name="history_tra_content_1234ab_idx",
),
migrations.RenameIndex(
model_name="historicalslug",
new_name="history_tra_slug_f843aa_idx",
old_name="history_tra_slug_1234ab_idx",
),
migrations.AlterField(
model_name="historicalslug",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -1,74 +0,0 @@
# history_tracking/mixins.py
from django.db import models
from django.conf import settings
class HistoricalChangeMixin(models.Model):
"""Mixin for historical models to track changes"""
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
history_date = models.DateTimeField()
history_id = models.AutoField(primary_key=True)
history_type = models.CharField(max_length=1)
history_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
related_name='+'
)
history_change_reason = models.CharField(max_length=100, null=True)
class Meta:
abstract = True
ordering = ['-history_date', '-history_id']
@property
def prev_record(self):
"""Get the previous record for this instance"""
try:
return self.__class__.objects.filter(
history_date__lt=self.history_date,
id=self.id
).order_by('-history_date').first()
except (AttributeError, TypeError):
return None
@property
def diff_against_previous(self):
prev_record = self.prev_record
if not prev_record:
return {}
changes = {}
for field in self.__dict__:
if field not in [
"history_date",
"history_id",
"history_type",
"history_user_id",
"history_change_reason",
"history_type",
"id",
"_state",
"_history_user_cache"
] and not field.startswith("_"):
try:
old_value = getattr(prev_record, field)
new_value = getattr(self, field)
if old_value != new_value:
changes[field] = {"old": str(old_value), "new": str(new_value)}
except AttributeError:
continue
return changes
@property
def history_user_display(self):
"""Get a display name for the history user"""
if hasattr(self, 'history_user') and self.history_user:
return str(self.history_user)
return None
def get_instance(self):
"""Get the model instance this history record represents"""
try:
return self.__class__.objects.get(id=self.id)
except self.__class__.DoesNotExist:
return None

View File

@@ -1,34 +1,70 @@
# history_tracking/models.py
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from simple_history.models import HistoricalRecords
from .mixins import HistoricalChangeMixin
from typing import Any, Type, TypeVar, cast
from django.conf import settings
from typing import Any, Dict, Optional
from django.db.models import QuerySet
T = TypeVar('T', bound=models.Model)
class DiffMixin:
"""Mixin to add diffing capabilities to models"""
class HistoricalModel(models.Model):
"""Abstract base class for models with history tracking"""
id = models.BigAutoField(primary_key=True)
history: HistoricalRecords = HistoricalRecords(
inherit=True,
bases=(HistoricalChangeMixin,)
)
def get_prev_record(self) -> Optional[Any]:
"""Get the previous record for this instance"""
try:
return type(self).objects.filter(
pgh_created_at__lt=self.pgh_created_at,
pgh_obj_id=self.pgh_obj_id
).order_by('-pgh_created_at').first()
except (AttributeError, TypeError):
return None
def diff_against_previous(self) -> Dict:
"""Compare this record against the previous one"""
prev_record = self.get_prev_record()
if not prev_record:
return {}
skip_fields = {
'pgh_id', 'pgh_created_at', 'pgh_label',
'pgh_obj_id', 'pgh_context_id', '_state',
'created_at', 'updated_at'
}
changes = {}
for field, value in self.__dict__.items():
# Skip internal fields and those we don't want to track
if field.startswith('_') or field in skip_fields or field.endswith('_id'):
continue
try:
old_value = getattr(prev_record, field)
new_value = value
if old_value != new_value:
changes[field] = {
"old": str(old_value) if old_value is not None else "None",
"new": str(new_value) if new_value is not None else "None"
}
except AttributeError:
continue
return changes
class TrackedModel(models.Model):
"""Abstract base class for models that need history tracking"""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
@property
def _history_model(self) -> Type[T]:
"""Get the history model class"""
return cast(Type[T], self.history.model) # type: ignore
def get_history(self) -> QuerySet:
"""Get all history records for this instance"""
model = self._history_model
return model.objects.filter(id=self.pk).order_by('-history_date')
"""Get all history records for this instance in chronological order"""
event_model = self.events.model # pghistory provides this automatically
if event_model:
return event_model.objects.filter(
pgh_obj_id=self.pk
).order_by('-pgh_created_at')
return self.__class__.objects.none()
class HistoricalSlug(models.Model):
"""Track historical slugs for models"""
@@ -37,6 +73,13 @@ class HistoricalSlug(models.Model):
content_object = GenericForeignKey('content_type', 'object_id')
slug = models.SlugField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='historical_slugs'
)
class Meta:
unique_together = ('content_type', 'slug')

View File

@@ -1,5 +1,7 @@
from django.apps import AppConfig
import os
class LocationConfig(AppConfig):
path = os.path.dirname(os.path.abspath(__file__))
default_auto_field = 'django.db.models.BigAutoField'
name = 'location'

View File

@@ -1,10 +1,10 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.contrib.gis.db.models.fields
import django.core.validators
import django.db.models.deletion
import simple_history.models
from django.conf import settings
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
@@ -14,140 +14,14 @@ class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="HistoricalLocation",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("object_id", models.PositiveIntegerField()),
(
"name",
models.CharField(
help_text="Name of the location (e.g. business name, landmark)",
max_length=255,
),
),
(
"location_type",
models.CharField(
help_text="Type of location (e.g. business, landmark, address)",
max_length=50,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
("city", models.CharField(blank=True, max_length=100, null=True)),
(
"state",
models.CharField(
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
("country", models.CharField(blank=True, max_length=100, null=True)),
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
("created_at", models.DateTimeField(blank=True, editable=False)),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"content_type",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="contenttypes.contenttype",
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "historical location",
"verbose_name_plural": "historical locations",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="Location",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("id", models.BigAutoField(primary_key=True, serialize=False)),
("object_id", models.PositiveIntegerField()),
(
"name",
@@ -228,16 +102,163 @@ class Migration(migrations.Migration):
],
options={
"ordering": ["name"],
"indexes": [
models.Index(
},
),
migrations.CreateModel(
name="LocationEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("object_id", models.PositiveIntegerField()),
(
"name",
models.CharField(
help_text="Name of the location (e.g. business name, landmark)",
max_length=255,
),
),
(
"location_type",
models.CharField(
help_text="Type of location (e.g. business, landmark, address)",
max_length=50,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
("city", models.CharField(blank=True, max_length=100, null=True)),
(
"state",
models.CharField(
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
("country", models.CharField(blank=True, max_length=100, null=True)),
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="location.location",
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="location",
index=models.Index(
fields=["content_type", "object_id"],
name="location_lo_content_9ee1bd_idx",
),
models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
models.Index(
),
migrations.AddIndex(
model_name="location",
index=models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
),
migrations.AddIndex(
model_name="location",
index=models.Index(
fields=["country"], name="location_lo_country_b75eba_idx"
),
],
},
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_98cd4",
table="location_location",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="location",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_471d2",
table="location_location",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("location", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="location",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -3,10 +3,12 @@ from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator, MaxValueValidator
from simple_history.models import HistoricalRecords
from django.contrib.gis.geos import Point
import pghistory
from history_tracking.models import TrackedModel
class Location(models.Model):
@pghistory.track()
class Location(TrackedModel):
"""
A generic location model that can be associated with any model
using GenericForeignKey. Stores detailed location information
@@ -63,7 +65,6 @@ class Location(models.Model):
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
history = HistoricalRecords()
class Meta:
indexes = [

View File

@@ -4,32 +4,32 @@ from django.core.exceptions import ValidationError
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
from .models import Location
from companies.models import Company
from operators.models import Operator
from parks.models import Park
class LocationModelTests(TestCase):
def setUp(self):
# Create test company
self.company = Company.objects.create(
name='Test Company',
self.operator = Operator.objects.create(
name='Test Operator',
website='http://example.com'
)
# Create test park
self.park = Park.objects.create(
name='Test Park',
owner=self.company,
owner=self.operator,
status='OPERATING'
)
# Create test location for company
self.company_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
name='Test Company HQ',
self.operator_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Operator),
object_id=self.operator.pk,
name='Test Operator HQ',
location_type='business',
street_address='123 Company St',
city='Company City',
street_address='123 Operator St',
city='Operator City',
state='CS',
country='Test Country',
postal_code='12345',
@@ -53,14 +53,14 @@ class LocationModelTests(TestCase):
def test_location_creation(self):
"""Test location instance creation and field values"""
# Test company location
self.assertEqual(self.company_location.name, 'Test Company HQ')
self.assertEqual(self.company_location.location_type, 'business')
self.assertEqual(self.company_location.street_address, '123 Company St')
self.assertEqual(self.company_location.city, 'Company City')
self.assertEqual(self.company_location.state, 'CS')
self.assertEqual(self.company_location.country, 'Test Country')
self.assertEqual(self.company_location.postal_code, '12345')
self.assertIsNotNone(self.company_location.point)
self.assertEqual(self.operator_location.name, 'Test Operator HQ')
self.assertEqual(self.operator_location.location_type, 'business')
self.assertEqual(self.operator_location.street_address, '123 Operator St')
self.assertEqual(self.operator_location.city, 'Operator City')
self.assertEqual(self.operator_location.state, 'CS')
self.assertEqual(self.operator_location.country, 'Test Country')
self.assertEqual(self.operator_location.postal_code, '12345')
self.assertIsNotNone(self.operator_location.point)
# Test park location
self.assertEqual(self.park_location.name, 'Test Park Location')
@@ -74,23 +74,23 @@ class LocationModelTests(TestCase):
def test_location_str_representation(self):
"""Test string representation of location"""
expected_company_str = 'Test Company HQ (Company City, Test Country)'
self.assertEqual(str(self.company_location), expected_company_str)
expected_company_str = 'Test Operator HQ (Operator City, Test Country)'
self.assertEqual(str(self.operator_location), expected_company_str)
expected_park_str = 'Test Park Location (Park City, Test Country)'
self.assertEqual(str(self.park_location), expected_park_str)
def test_get_formatted_address(self):
"""Test get_formatted_address method"""
expected_address = '123 Company St, Company City, CS, 12345, Test Country'
self.assertEqual(self.company_location.get_formatted_address(), expected_address)
expected_address = '123 Operator St, Operator City, CS, 12345, Test Country'
self.assertEqual(self.operator_location.get_formatted_address(), expected_address)
def test_point_coordinates(self):
"""Test point coordinates"""
# Test company location point
self.assertIsNotNone(self.company_location.point)
self.assertAlmostEqual(self.company_location.point.y, 34.0522, places=4) # latitude
self.assertAlmostEqual(self.company_location.point.x, -118.2437, places=4) # longitude
self.assertIsNotNone(self.operator_location.point)
self.assertAlmostEqual(self.operator_location.point.y, 34.0522, places=4) # latitude
self.assertAlmostEqual(self.operator_location.point.x, -118.2437, places=4) # longitude
# Test park location point
self.assertIsNotNone(self.park_location.point)
@@ -99,7 +99,7 @@ class LocationModelTests(TestCase):
def test_coordinates_property(self):
"""Test coordinates property"""
company_coords = self.company_location.coordinates
company_coords = self.operator_location.coordinates
self.assertIsNotNone(company_coords)
self.assertAlmostEqual(company_coords[0], 34.0522, places=4) # latitude
self.assertAlmostEqual(company_coords[1], -118.2437, places=4) # longitude
@@ -111,7 +111,7 @@ class LocationModelTests(TestCase):
def test_distance_calculation(self):
"""Test distance_to method"""
distance = self.company_location.distance_to(self.park_location)
distance = self.operator_location.distance_to(self.park_location)
self.assertIsNotNone(distance)
self.assertGreater(distance, 0)
@@ -119,17 +119,17 @@ class LocationModelTests(TestCase):
"""Test nearby_locations method"""
# Create another location near the company location
nearby_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
content_type=ContentType.objects.get_for_model(Operator),
object_id=self.operator.pk,
name='Nearby Location',
location_type='business',
street_address='789 Nearby St',
city='Company City',
city='Operator City',
country='Test Country',
point=Point(-118.2438, 34.0523) # Very close to company location
)
nearby = self.company_location.nearby_locations(distance_km=1)
nearby = self.operator_location.nearby_locations(distance_km=1)
self.assertEqual(nearby.count(), 1)
self.assertEqual(nearby.first(), nearby_location)
@@ -137,10 +137,10 @@ class LocationModelTests(TestCase):
"""Test generic relations work correctly"""
# Test company location relation
company_location = Location.objects.get(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk
content_type=ContentType.objects.get_for_model(Operator),
object_id=self.operator.pk
)
self.assertEqual(company_location, self.company_location)
self.assertEqual(company_location, self.operator_location)
# Test park location relation
park_location = Location.objects.get(
@@ -152,19 +152,19 @@ class LocationModelTests(TestCase):
def test_location_updates(self):
"""Test location updates"""
# Update company location
self.company_location.street_address = 'Updated Address'
self.company_location.city = 'Updated City'
self.company_location.save()
self.operator_location.street_address = 'Updated Address'
self.operator_location.city = 'Updated City'
self.operator_location.save()
updated_location = Location.objects.get(pk=self.company_location.pk)
updated_location = Location.objects.get(pk=self.operator_location.pk)
self.assertEqual(updated_location.street_address, 'Updated Address')
self.assertEqual(updated_location.city, 'Updated City')
def test_point_sync_with_lat_lon(self):
"""Test point synchronization with latitude/longitude fields"""
location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
content_type=ContentType.objects.get_for_model(Operator),
object_id=self.operator.pk,
name='Test Sync Location',
location_type='business',
latitude=34.0522,

View File

@@ -9,7 +9,10 @@ from django.views.decorators.http import require_http_methods
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from django.db.models import Q
from location.forms import LocationForm
from .models import Location
from security import safe_requests
class LocationSearchView(View):
"""
@@ -49,11 +52,11 @@ class LocationSearchView(View):
elif filter_type == 'city':
params['featuretype'] = 'city'
response = requests.get(
response = safe_requests.get(
'https://nominatim.openstreetmap.org/search',
params=params,
headers={'User-Agent': 'ThrillWiki/1.0'}
)
headers={'User-Agent': 'ThrillWiki/1.0'},
timeout=60)
response.raise_for_status()
results = response.json()
except requests.RequestException as e:
@@ -162,7 +165,7 @@ def reverse_geocode(request):
return JsonResponse(cached_result)
try:
response = requests.get(
response = safe_requests.get(
'https://nominatim.openstreetmap.org/reverse',
params={
'lat': lat,
@@ -170,8 +173,8 @@ def reverse_geocode(request):
'format': 'json',
'addressdetails': 1
},
headers={'User-Agent': 'ThrillWiki/1.0'}
)
headers={'User-Agent': 'ThrillWiki/1.0'},
timeout=60)
response.raise_for_status()
result = response.json()

14
manufacturers/admin.py Normal file
View File

@@ -0,0 +1,14 @@
from django.contrib import admin
from .models import Manufacturer
class ManufacturerAdmin(admin.ModelAdmin):
list_display = ('name', 'headquarters', 'founded_year', 'rides_count', 'coasters_count', 'created_at', 'updated_at')
list_filter = ('founded_year',)
search_fields = ('name', 'description', 'headquarters')
readonly_fields = ('created_at', 'updated_at', 'rides_count', 'coasters_count')
prepopulated_fields = {'slug': ('name',)}
# Register the model with admin
admin.site.register(Manufacturer, ManufacturerAdmin)

6
manufacturers/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ManufacturersConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'manufacturers'

View File

@@ -0,0 +1,119 @@
# Generated by Django 5.1.4 on 2025-07-04 14:50
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
]
operations = [
migrations.CreateModel(
name="Manufacturer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)),
("description", models.TextField(blank=True)),
("website", models.URLField(blank=True)),
("founded_year", models.PositiveIntegerField(blank=True, null=True)),
("headquarters", models.CharField(blank=True, max_length=255)),
("rides_count", models.IntegerField(default=0)),
("coasters_count", models.IntegerField(default=0)),
],
options={
"verbose_name": "Manufacturer",
"verbose_name_plural": "Manufacturers",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="ManufacturerEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(db_index=False, max_length=255)),
("description", models.TextField(blank=True)),
("website", models.URLField(blank=True)),
("founded_year", models.PositiveIntegerField(blank=True, null=True)),
("headquarters", models.CharField(blank=True, max_length=255)),
("rides_count", models.IntegerField(default=0)),
("coasters_count", models.IntegerField(default=0)),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="manufacturer",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "manufacturers_manufacturerevent" ("coasters_count", "created_at", "description", "founded_year", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_year", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rides_count", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_e3fce",
table="manufacturers_manufacturer",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="manufacturer",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "manufacturers_manufacturerevent" ("coasters_count", "created_at", "description", "founded_year", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rides_count", "slug", "updated_at", "website") VALUES (NEW."coasters_count", NEW."created_at", NEW."description", NEW."founded_year", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rides_count", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_5d619",
table="manufacturers_manufacturer",
when="AFTER",
),
),
),
migrations.AddField(
model_name="manufacturerevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="manufacturerevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="manufacturers.manufacturer",
),
),
]

65
manufacturers/models.py Normal file
View File

@@ -0,0 +1,65 @@
from django.db import models
from django.utils.text import slugify
from django.urls import reverse
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
import pghistory
from history_tracking.models import TrackedModel, HistoricalSlug
@pghistory.track()
class Manufacturer(TrackedModel):
"""
Companies that manufacture rides (enhanced from existing, separate from companies)
"""
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
description = models.TextField(blank=True)
website = models.URLField(blank=True)
founded_year = models.PositiveIntegerField(blank=True, null=True)
headquarters = models.CharField(max_length=255, blank=True)
rides_count = models.IntegerField(default=0)
coasters_count = models.IntegerField(default=0)
objects: ClassVar[models.Manager['Manufacturer']]
class Meta:
ordering = ['name']
verbose_name = 'Manufacturer'
verbose_name_plural = 'Manufacturers'
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
def get_absolute_url(self) -> str:
return reverse('manufacturers:detail', kwargs={'slug': self.slug})
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]:
"""Get manufacturer by slug, checking historical slugs if needed"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check pghistory first
history_model = cls.get_history_model()
history_entry = (
history_model.objects.filter(slug=slug)
.order_by('-pgh_created_at')
.first()
)
if history_entry:
return cls.objects.get(id=history_entry.pgh_obj_id), True
# Check manual slug history as fallback
try:
historical = HistoricalSlug.objects.get(
content_type__model='manufacturer',
slug=slug
)
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()

3
manufacturers/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
manufacturers/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "manufacturers"
urlpatterns = [
# Manufacturer list and detail views
path("", views.ManufacturerListView.as_view(), name="manufacturer_list"),
path("<slug:slug>/", views.ManufacturerDetailView.as_view(), name="manufacturer_detail"),
]

43
manufacturers/views.py Normal file
View File

@@ -0,0 +1,43 @@
from django.views.generic import ListView, DetailView
from django.db.models import QuerySet
from django.core.exceptions import ObjectDoesNotExist
from core.views import SlugRedirectMixin
from .models import Manufacturer
from typing import Optional, Any, Dict
class ManufacturerListView(ListView):
model = Manufacturer
template_name = "manufacturers/manufacturer_list.html"
context_object_name = "manufacturers"
paginate_by = 20
def get_queryset(self) -> QuerySet[Manufacturer]:
return Manufacturer.objects.all().order_by('name')
class ManufacturerDetailView(SlugRedirectMixin, DetailView):
model = Manufacturer
template_name = "manufacturers/manufacturer_detail.html"
context_object_name = "manufacturer"
def get_object(self, queryset: Optional[QuerySet[Manufacturer]] = None) -> Manufacturer:
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
if slug is None:
raise ObjectDoesNotExist("No slug provided")
manufacturer, _ = Manufacturer.get_by_slug(slug)
return manufacturer
def get_queryset(self) -> QuerySet[Manufacturer]:
return Manufacturer.objects.all()
def get_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
manufacturer = self.get_object()
# Add related rides to context (using related_name="rides" from Ride model)
context['rides'] = manufacturer.rides.all().order_by('name')
return context

View File

@@ -1,5 +1,4 @@
import os
import requests
from django.core.management.base import BaseCommand
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
@@ -9,6 +8,7 @@ from rides.models import Ride
from django.contrib.contenttypes.models import ContentType
import json
from django.core.files.base import ContentFile
from security import safe_requests
class Command(BaseCommand):
help = 'Download photos from seed data URLs'
@@ -33,7 +33,7 @@ class Command(BaseCommand):
try:
# Download image
self.stdout.write(f'Downloading from URL: {photo_url}')
response = requests.get(photo_url)
response = safe_requests.get(photo_url, timeout=60)
if response.status_code == 200:
# Delete any existing photos for this park
Photo.objects.filter(
@@ -74,7 +74,7 @@ class Command(BaseCommand):
try:
# Download image
self.stdout.write(f'Downloading from URL: {photo_url}')
response = requests.get(photo_url)
response = safe_requests.get(photo_url, timeout=60)
if response.status_code == 200:
# Delete any existing photos for this ride
Photo.objects.filter(

View File

@@ -1,8 +1,10 @@
# Generated by Django 5.1.3 on 2024-11-12 18:07
# Generated by Django 5.1.4 on 2025-02-10 01:10
import django.db.models.deletion
import media.models
import media.storage
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
from django.db import migrations, models
@@ -13,6 +15,7 @@ class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -20,15 +23,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="Photo",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("id", models.BigAutoField(primary_key=True, serialize=False)),
(
"image",
models.ImageField(
@@ -64,12 +59,110 @@ class Migration(migrations.Migration):
],
options={
"ordering": ["-is_primary", "-created_at"],
"indexes": [
models.Index(
fields=["content_type", "object_id"],
name="media_photo_content_0187f5_idx",
)
],
},
),
migrations.CreateModel(
name="PhotoEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
(
"image",
models.ImageField(
max_length=255,
storage=media.storage.MediaStorage(),
upload_to=media.models.photo_upload_path,
),
),
("caption", models.CharField(blank=True, max_length=255)),
("alt_text", models.CharField(blank=True, max_length=255)),
("is_primary", models.BooleanField(default=False)),
("is_approved", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)),
("object_id", models.PositiveIntegerField()),
(
"content_type",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"pgh_context",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
(
"pgh_obj",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="media.photo",
),
),
(
"uploaded_by",
models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="photo",
index=models.Index(
fields=["content_type", "object_id"],
name="media_photo_content_0187f5_idx",
),
),
pgtrigger.migrations.AddTrigger(
model_name="photo",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_e1ca0",
table="media_photo",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photo",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_6ff7d",
table="media_photo",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="photo",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -11,6 +11,8 @@ from datetime import datetime
from .storage import MediaStorage
from rides.models import Ride
from django.utils import timezone
from history_tracking.models import TrackedModel
import pghistory
def photo_upload_path(instance: models.Model, filename: str) -> str:
"""Generate upload path for photos using normalized filenames"""
@@ -38,7 +40,8 @@ def photo_upload_path(instance: models.Model, filename: str) -> str:
# For park photos, store directly in park directory
return f"park/{identifier}/{base_filename}"
class Photo(models.Model):
@pghistory.track()
class Photo(TrackedModel):
"""Generic photo model that can be attached to any model"""
image = models.ImageField(
upload_to=photo_upload_path, # type: ignore[arg-type]

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Some files were not shown because too many files have changed in this diff Show More