From 3cbda93094360dafef638bebe0e78dcff53c513d Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Thu, 31 Oct 2024 22:32:01 +0000 Subject: [PATCH] here we go --- accounts/__pycache__/urls.cpython-312.pyc | Bin 1986 -> 1986 bytes accounts/__pycache__/views.cpython-312.pyc | Bin 12127 -> 12127 bytes companies/__pycache__/urls.cpython-312.pyc | Bin 916 -> 1542 bytes companies/__pycache__/views.cpython-312.pyc | Bin 5784 -> 15574 bytes companies/forms.py | 46 +++ companies/urls.py | 4 + companies/views.py | 160 ++++++++- moderation/admin.py | 26 +- .../0003_rename_fields_and_update_status.py | 107 ++++++ moderation/mixins.py | 8 +- moderation/models.py | 92 +++--- moderation/urls.py | 17 +- moderation/views.py | 166 +++++----- parks/__pycache__/admin.cpython-312.pyc | Bin 1166 -> 2601 bytes parks/__pycache__/models.cpython-312.pyc | Bin 7202 -> 7240 bytes parks/__pycache__/urls.cpython-312.pyc | Bin 1245 -> 1387 bytes parks/__pycache__/views.cpython-312.pyc | Bin 11951 -> 16691 bytes parks/admin.py | 46 ++- parks/forms.py | 106 ++++-- .../commands/fix_historical_parks.py | 99 ++++++ parks/management/commands/fix_locations.py | 90 ++++++ parks/management/commands/seed_data.json | 97 +++++- parks/management/commands/seed_data.py | 149 +++++---- .../0007_fix_historical_park_city_null.py | 17 + .../0008_fix_historical_park_data.py | 43 +++ .../0009_fix_historical_park_fields.py | 33 ++ ...storicalpark_country_alter_park_country.py | 34 ++ .../migrations/0010_fix_historical_records.py | 72 +++++ .../0011_alter_historicalpark_fields.py | 52 +++ parks/migrations/0011_merge_20241031_1617.py | 13 + parks/migrations/0012_merge_20241031_1635.py | 13 + parks/migrations/0013_fix_null_locations.py | 67 ++++ .../migrations/0014_alter_location_fields.py | 52 +++ ...015_fix_historical_park_city_constraint.py | 16 + ...0016_alter_historicalpark_city_nullable.py | 22 ++ .../0017_fix_historicalpark_city_column.py | 22 ++ ...0018_fix_historicalpark_location_fields.py | 48 +++ ...19_fix_historicalpark_region_constraint.py | 15 + .../0020_remove_historicalpark_city_text.py | 16 + parks/models.py | 5 +- parks/urls.py | 1 + parks/views.py | 157 +++++++-- rides/__pycache__/urls.cpython-312.pyc | Bin 708 -> 855 bytes rides/__pycache__/views.cpython-312.pyc | Bin 10089 -> 14095 bytes rides/forms.py | 71 ++++ rides/urls.py | 1 + rides/views.py | 98 +++++- static/css/alerts.css | 44 +++ static/css/inline-edit.css | 187 ----------- static/css/tailwind.css | 305 +++++++++++------- static/js/alerts.js | 18 ++ static/js/alpine.min.js | 5 + static/js/inline-edit.js | 262 --------------- static/js/location-autocomplete.js | 81 +++++ templates/base/base.html | 140 +++++--- templates/companies/company_detail.html | 65 ++-- templates/companies/company_form.html | 128 ++++++++ templates/companies/manufacturer_detail.html | 19 +- templates/companies/manufacturer_form.html | 128 ++++++++ templates/moderation/edit_submissions.html | 127 ++++++++ .../moderation/partials/submission_list.html | 88 +++++ templates/parks/park_detail.html | 57 +--- templates/parks/park_form.html | 233 ++++++------- templates/parks/park_list.html | 266 +++++++-------- templates/rides/ride_detail.html | 103 ++---- templates/rides/ride_form.html | 13 +- .../__pycache__/settings.cpython-312.pyc | Bin 5880 -> 5792 bytes thrillwiki/settings.py | 249 +++++++------- 68 files changed, 3114 insertions(+), 1485 deletions(-) create mode 100644 companies/forms.py create mode 100644 moderation/migrations/0003_rename_fields_and_update_status.py create mode 100644 parks/management/commands/fix_historical_parks.py create mode 100644 parks/management/commands/fix_locations.py create mode 100644 parks/migrations/0007_fix_historical_park_city_null.py create mode 100644 parks/migrations/0008_fix_historical_park_data.py create mode 100644 parks/migrations/0009_fix_historical_park_fields.py create mode 100644 parks/migrations/0010_alter_historicalpark_country_alter_park_country.py create mode 100644 parks/migrations/0010_fix_historical_records.py create mode 100644 parks/migrations/0011_alter_historicalpark_fields.py create mode 100644 parks/migrations/0011_merge_20241031_1617.py create mode 100644 parks/migrations/0012_merge_20241031_1635.py create mode 100644 parks/migrations/0013_fix_null_locations.py create mode 100644 parks/migrations/0014_alter_location_fields.py create mode 100644 parks/migrations/0015_fix_historical_park_city_constraint.py create mode 100644 parks/migrations/0016_alter_historicalpark_city_nullable.py create mode 100644 parks/migrations/0017_fix_historicalpark_city_column.py create mode 100644 parks/migrations/0018_fix_historicalpark_location_fields.py create mode 100644 parks/migrations/0019_fix_historicalpark_region_constraint.py create mode 100644 parks/migrations/0020_remove_historicalpark_city_text.py create mode 100644 rides/forms.py create mode 100644 static/css/alerts.css delete mode 100644 static/css/inline-edit.css create mode 100644 static/js/alerts.js create mode 100644 static/js/alpine.min.js delete mode 100644 static/js/inline-edit.js create mode 100644 static/js/location-autocomplete.js create mode 100644 templates/companies/company_form.html create mode 100644 templates/companies/manufacturer_form.html create mode 100644 templates/moderation/edit_submissions.html create mode 100644 templates/moderation/partials/submission_list.html diff --git a/accounts/__pycache__/urls.cpython-312.pyc b/accounts/__pycache__/urls.cpython-312.pyc index 04cdef272e5c33af51bfe9be7ac47844e51fe57e..64c3eb6f26c31622d45eab96fbddd87c4196f093 100644 GIT binary patch delta 20 acmX@ae~6#^G%qg~0}vQpR^G_HogDx;!3946 delta 20 acmX@ae~6#^G%qg~0}xEiP}<17ogDx;(*-gB diff --git a/accounts/__pycache__/views.cpython-312.pyc b/accounts/__pycache__/views.cpython-312.pyc index 566b8e1d6d422ad6ec42324ad20895380e8cacef..3f1bcf4e579a54265346b2cbfcdbc2bd039d4bdc 100644 GIT binary patch delta 20 acmcZ~cR!B%G%qg~0}zN@R^G@Rq7MK@y9K@g delta 20 acmcZ~cR!B%G%qg~0}xEiP};~Hq7MK^p9RkV diff --git a/companies/__pycache__/urls.cpython-312.pyc b/companies/__pycache__/urls.cpython-312.pyc index 325c522628537a1a1b5812859b8bda2457605494..224eb27808b9b868cd4beb5d5722c934e58f1f4d 100644 GIT binary patch delta 519 zcmbQj-o~SGnwOW00SJ;0D5qyIGcY^`abSQ4%J}TUv{A!=k&B5Tl`(}cl{rgfvK*tn zDE}Jv)j%yE0G1H|%7}qw1kq%KfHD$b8DTUT5ul6|NJdx`A(PG##aAh&DLy%Y(cY3h zxhOTUBvtgv@eQiUl%dH zC}MnB#PouV&lMKmn=Bj`IAvy}UY9eyC}(E@2 z6YPNSwW-eZ?i%=`GAK$R*R;Wc_M%EBrBbWjNtLcuq;^J&w0k3HB3-2_|Do8ui}=o!t!cYy6=<75TvORhD5E z8IIvtfl0CvHff1ilGcbdX^Yrc7udC5Zh8F zwwuNVA$A>axns@e2~+NR;I2ylu$9N91#ih?-%`jn$>Ksfbr(>yE z)q5zGnmH4TOEZKg5CH|!h`ni&RI3Xkkpsor#u6M>Wjhih=ips4F3gerolJ;gBAudaweHAdT1uC4y(2I-X)>$#sH!+?TpMwXV&m~y?f|mL@Jxi|n23e9 zMr^!2;^5eble4_dL|mK|XvM58Y*%aY9Wn?0QA?cmF~N)dT4*+U20GU_DJ6xth1Q@E z54>>7m;VIlB6E?QDu~pBQ~5=q|Hk*7{Etf(DZiK*X39~RLJLpno4FwBA~Qude*p`E zQ{Lj=_)DtNd1cG4(PPcGRI7*Y(GKwUZjM~ z3PY1IF?5>eQy~#VbqHil2;@2_hPehq67A3>kpSW_+yt!`_*jbPqM#yTsx6+Lo+Y@r zRi~ySMAf0yi#p>1OX+~7tFi4yL%{VBWR@b4)~(iy?R(z1lySPV)WJv3S2UNfLtaX5w6;AQKgnq1ZP)tGcm{==qqC;O2sZng%lkexDgz54DO1K=#cuxj;)c(5(cz zvw_V@U~?wWzu;YQFby5q`W~geCtJT&so$Ebsb36U3ubHDm74bZO&!^$UZtrw+q6Sz z+VS__$ute-f*rZKP|hD%9Jn@+^>--#j;z01@ps>D{Ag>&zkT(Xhm}mji_46&!M)&q zSjTv)-x;_vko5!=PcZ9gQ#@^Q=ZNAN$u+OP8NLyIw`XDKM+b5nx<1_b{>~3~y}xT= z|2=Og7utmHb-9l24-dY7Fx&Bx((w{<+H&58cMe}U3~~3pby;sv@dmF?+&p>Xq`djn zpKtr+&R^`z4vs5>bu<33!#~xVBM?|*BqNSvIn*|V zIu_HzGiB&W?Kjc{d)Lq`7zG$BkqO`~Sya0?GYxXS8t043 z`U?`3mP;Q=R!3wIUj&qZ^ItqGw(k$diz4u06jD%pUZW7W*AYM}g;;un=kk z*SeJ2O-k*y+sA+`ICJaT7Mxcr6jyW3*RW7Uhr0+%qNJpI;@^S(ZemH;+2t@}1N6M) zc>?MzgW$@cqBWq-wU$(jI`hZEuZ23x;6;i$7Xfvm8Fv8fX$m(ugrL zmpcO#p-$_QY6C>qsh2giQ4g8#@^w=DSZ-F!AkY&0*rS&XB^y{$=b2|D6W2Xe`zlsh zb!FSfx!*H13XF%*Ldn3loQLzSQX&ejk_R+YrH)_CeuS$=Ron2;YZDr_#1)_cOs9s? zHX!3=Bppa_R!kI1aB9dVASEz!hFS}_(4%S%W@_*?B&9=-ngL;+=gUG&vJZ71K8Cf(Tvy=GE{Zunf>vAb(BJhNX$q&l0pXG%udJc5X3sEtLzlKP`UqQT*nj z_^n}$`0d-pYvETu?)b-^zw62F*stu^FTZj?KETUeXYRSq=0V(E`N%Q(_{r??xNIZcZh=nbafe+1c)y-+eafaD-f^=cTcU~!aSv}2g}5E2Ao zVJ{iQ@L?oJkl+c3{5}#Cv1AO%IFge{a0SwZs#fMfLYx)+S`iZ5 z2_kOfFCT+lD>kb=@P)h4F0j8Tz+RXu4*$lZkL%a5W;GOAU{5>E1CxcuVp&+XM6`_*MV(fZ-gb zL-IQO8)lkDG}E-9nP$6Yra4H>G$-osLEzOi`W(-cMz# zA;%#ni6A)zL`VCD)|Rfq*)kjQn8{@x3;6>i2mIz3XVbn zYlM^hccL}Je0w+LFtVXc<|Uc3wZK4;HC+@dFaiPjov)j(W8gnIl1A^$!YlYm~K{c zCXO`xdNc@0J$vvfh`Kfv;Rq|V0{@rq0>NvU`D!r#7J?z*<8w!QF_&0~*YQ}Mhp#C# zjg>r*(ow>KpSpy;gjb=i$K$6EdX+pEEqX4FZGt$s60?jFYzFu{cyTR&4*;1Jd5Kyo!W@24E!3YlOkQR(=iFgA~m-ue=}RszjA4RhW*5C2rJOnN|(=d*C-!tUW|qMKi@Az472q#%K8(O2wiho;hT5?Zy$i*gmYyi6$Stg@^Co z4IxQTVUdeS=7AJRHiA<;R|LS!0V|Me|A4vw2nlM|^5^7R5VMLrvVZ&X$n>HFiDWY* zk^>|Xxe6^+Z77P=%6K{^!XAsFua~IW2{`takx#XdL%SvX0fs0jUck_rPWL54s&6&) zYWpWBM8xZvHHf9AbvIjXw3LdahFh%}_X|%enEZw7{Ylb^pa_`jFEm<|bgP^+`UJbD zOQQ~L-~R}BkIN$yo1r`+KSqL!^Q+{~JD3K^$MA2;AB1IfFQfeFeoX#!YVE*GMe;|N z;>#$-iao0hOlAe|YV`bZ&Dz5b)t8scx@-HKX<9bW7h07y*-~N1pU=Hmw_YEFDg2^Z zV+gq57lb_etQBV6$)~5#tPJ2{BK;NRYw-!Z(?1w zM(bUhk&cB{;esi7IJ=$#prKzz2dXPgIFKQyXLYx$?*o@0KqI$+ z=;putW&b5$88A{`I#k%|5cD2qLU9XN#oxTR``YfTf1~2xnDKWnxBz*8r)BT8-fV5V zQrn)b-Kf-VyfvGtePO}*D^G1M(7Jf}+U1Ymym~nk*takO$pOz$|IId~rY&33snm4d zn#|PnFF1bXsmUk*=sYIhwJ`FTFOcktt9qtm@O=h4 z?SZQ-cC*wHd;@t%{uTbkAQ1iL7=86}&7H7WbG3Ez2bOJ>_OP~3F?rEGHE;s@U#xn1 zGIlCGKiT2@WS*QHYF(iq)8}ArwHst}4_6k-OOd>bZF|0tVHD7FQQv*favziTplH~I zxZO1k{1XgqLGl+!UPMB*@&_3DOCaU;TM@?5C%kCW;YUj%o>FkrwPyWYioYxC?@|0c z8UN=0oo!dNv`~$D;D31sEVxyGmdgZMaIUsN>XN72S-ji_IJ!Ck@Wdi`k_=b8vL%#J z5u61am-AcWD{7$d>}hJ?>~|fy>)BV-;WQv%#i;6+(o#%_7VYJ|paSX&IiMxP6nxyE zdbHFO8mgAD!LWt=H4KUBii4y(3!iPN_4yQOK>#(7hm>VtB)v7Gs>H6TCW3gJ zhA|Fr6LZaBU6Fi&isZNJJ@&vu28gCoFz0BvN3GL7tLW3dp*YKfogxt@R){6IcL;7d zf?!Im&ZiV7(?p8TNFs3}+mmO5eMzJqerj``gR?^tr~6_v(qvx}zf=?ne(0vQmn5Lm zn3C`V9kGu(!;9JnELFt`wNFp<3Mb*aM(x8BM?Mcd(4&-8?Pxd`H7_r`qE)?g>FR~! znp3^h5Tmou{Gz(!X~JtmLF$lEgC&!g1RpQzJ-|;#NCOgd7Aj7GpV^3-KP9@@lVeEE zArX;WK!TW?>VGhgOzlKV=SOm_rYkz6VU}1LlwvG+SjbXWGm;Buc`PgZhORX@#Tj{N1JLPqK@|Hn)_;vZj hSvetMMCVGcpABD~Tw&m~vMIy{uD|?WP`bAC{u?fk0fGPk delta 1758 zcma)6&2Jk;6rb_O+Oe~V^P!TECU)95@jB_oP17V8RDqO|MujAN6<8rw8}Gz%yK9G; zH6^{ILL326&4CIbByNNr+A2~HNQgfGA$me+P|1M<>IEUya6usEjqTE}1D5sMH^290 z-<$V-J3nk+KC<(zt|yTEUD`ZU{zpgX7g)4^TFba|O&T8~7rEF&KDMwQvO*ZvAusGl ztcbM3Zp4fFnx*+ME2ex=FYb3(9m>|cgrBsM%8q%RzHaFlh0xLta^vqIw}a>eUx_r+ zSzXeB+=S$l_wn6|@07ewLRZ4A=#*l+B-?!t+q0cAdTv9A5fX_-5;zs_Oof~m@#iWm z0L#Flr_BGvDQ<<*{FBhw-uL4^UJ=^4^A0UD5iXNJbTO}9w(Hch6)U2C%_W{lN}s*H zZG(3=EFz3}8zTNzozQJ2cSV3_P$0h@U*;d=a#-g#a%-VGh~xP|K5AUGN2OOpw;Xzo z?II&4qO86}s7S$^OSNi1-U)2i2^xu+znaz9N~p;CQ_0>t_C$w>`TQ=Q|H!M(pGSMi|~ta|_kB&hCYGO}!7Ak~FY z{*5`rujaHQ9FC3vq*cGKANdkL$=@(DC%|LG=ot7^X&qpV155zq01SY<1O|$fNiH_C zWcgR-6wdQs&DT{Yiw9JqX^3p~5Ox;$rRiDkD6s8Dj;Tgc;EW`lfv95uvjA}b=qf!9 zFb8md@8~>m3li?_R}n~n5q`AL>Q`}Zy^s#sA_8ME;#5rxjwiy7=ZP34Qlu;p;iZ!= z(|&jX99zU|)Fo6Zx~-qb!Lw&%TSF-TS%V;6-{$2FR1CuBpMT zz_wL@HGsRPqA$SmMF|hpe|l~luW~x~Ax`h%`A;#n_-6~*lMkswO+~dYTB_Bn0abEQ zN_mt%WU32UzBV_=h1rSUeKs z{q9+(TCSOI%gc?KWl|-yQleql0Xi0`mY;3bXi%yLjDkFcrmi&BlnQiJbkd4TnAzgt z$f8qSRBcLKNr_TzhmvMCnv_B6+f{;8LsVrxFk%sZO1`!3krQ-7?tqVmJul(+FvfTv z9lwRlePrB1!|I=FMEY^&=78}e6-m>^3lj=@MD)3 L{y_4fKDK`W9RG^h diff --git a/companies/forms.py b/companies/forms.py new file mode 100644 index 00000000..6a9a0937 --- /dev/null +++ b/companies/forms.py @@ -0,0 +1,46 @@ +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' + }), + } diff --git a/companies/urls.py b/companies/urls.py index ee57a601..03e79a8f 100644 --- a/companies/urls.py +++ b/companies/urls.py @@ -6,9 +6,13 @@ app_name = 'companies' urlpatterns = [ # Company URLs path('', views.CompanyListView.as_view(), name='company_list'), + path('create/', views.CompanyCreateView.as_view(), name='company_create'), + path('/edit/', views.CompanyUpdateView.as_view(), name='company_edit'), path('/', views.CompanyDetailView.as_view(), name='company_detail'), # Manufacturer URLs path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), + path('manufacturers/create/', views.ManufacturerCreateView.as_view(), name='manufacturer_create'), + path('manufacturers//edit/', views.ManufacturerUpdateView.as_view(), name='manufacturer_edit'), path('manufacturers//', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'), ] diff --git a/companies/views.py b/companies/views.py index 2a229689..db27238a 100644 --- a/companies/views.py +++ b/companies/views.py @@ -1,11 +1,165 @@ -from django.views.generic import DetailView, ListView +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 from .models import Company, Manufacturer +from .forms import CompanyForm, ManufacturerForm from rides.models import Ride from parks.models import Park from core.views import SlugRedirectMixin +from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin +from moderation.models import EditSubmission -class CompanyDetailView(SlugRedirectMixin, DetailView): +class CompanyCreateView(LoginRequiredMixin, CreateView): + model = Company + form_class = CompanyForm + template_name = 'companies/company_form.html' + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Company), + submission_type='CREATE', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.object_id = self.object.id + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully created {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, 'Your company submission has been sent for review') + return HttpResponseRedirect(reverse('companies:company_list')) + + def get_success_url(self): + return reverse('companies:company_detail', kwargs={'slug': self.object.slug}) + +class CompanyUpdateView(LoginRequiredMixin, UpdateView): + model = Company + form_class = CompanyForm + template_name = 'companies/company_form.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['is_edit'] = True + return context + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Company), + object_id=self.object.id, + submission_type='EDIT', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully updated {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') + return HttpResponseRedirect(reverse('companies:company_detail', kwargs={'slug': self.object.slug})) + + def get_success_url(self): + return reverse('companies:company_detail', kwargs={'slug': self.object.slug}) + +class ManufacturerCreateView(LoginRequiredMixin, CreateView): + model = Manufacturer + form_class = ManufacturerForm + template_name = 'companies/manufacturer_form.html' + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Manufacturer), + submission_type='CREATE', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.object_id = self.object.id + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully created {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, 'Your manufacturer submission has been sent for review') + return HttpResponseRedirect(reverse('companies:manufacturer_list')) + + def get_success_url(self): + return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug}) + +class ManufacturerUpdateView(LoginRequiredMixin, UpdateView): + model = Manufacturer + form_class = ManufacturerForm + template_name = 'companies/manufacturer_form.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['is_edit'] = True + return context + + def form_valid(self, form): + cleaned_data = form.cleaned_data.copy() + + # Create submission record + submission = EditSubmission.objects.create( + user=self.request.user, + content_type=ContentType.objects.get_for_model(Manufacturer), + object_id=self.object.id, + submission_type='EDIT', + changes=cleaned_data, + reason=self.request.POST.get('reason', ''), + source=self.request.POST.get('source', '') + ) + + # If user is moderator or above, auto-approve + if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + self.object = form.save() + submission.status = 'APPROVED' + submission.handled_by = self.request.user + submission.save() + messages.success(self.request, f'Successfully updated {self.object.name}') + return HttpResponseRedirect(self.get_success_url()) + + messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') + return HttpResponseRedirect(reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug})) + + def get_success_url(self): + return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug}) + +class CompanyDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): model = Company template_name = 'companies/company_detail.html' context_object_name = 'company' @@ -27,7 +181,7 @@ class CompanyDetailView(SlugRedirectMixin, DetailView): def get_redirect_url_pattern(self): return 'company_detail' -class ManufacturerDetailView(SlugRedirectMixin, DetailView): +class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): model = Manufacturer template_name = 'companies/manufacturer_detail.html' context_object_name = 'manufacturer' diff --git a/moderation/admin.py b/moderation/admin.py index 745dd646..43c377bb 100644 --- a/moderation/admin.py +++ b/moderation/admin.py @@ -16,10 +16,10 @@ class ModerationAdminSite(AdminSite): moderation_site = ModerationAdminSite(name='moderation') class EditSubmissionAdmin(admin.ModelAdmin): - list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'submitted_at', 'reviewed_by'] - list_filter = ['status', 'content_type', 'submitted_at'] - search_fields = ['user__username', 'reason', 'source', 'review_notes'] - readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'submitted_at'] + list_display = ['id', 'user_link', 'content_type', 'content_link', 'status', 'created_at', 'handled_by'] + list_filter = ['status', 'content_type', 'created_at'] + search_fields = ['user__username', 'reason', 'source', 'notes'] + readonly_fields = ['user', 'content_type', 'object_id', 'changes', 'created_at'] def user_link(self, obj): url = reverse('admin:accounts_user_change', args=[obj.user.id]) @@ -36,16 +36,18 @@ class EditSubmissionAdmin(admin.ModelAdmin): def save_model(self, request, obj, form, change): if 'status' in form.changed_data: if obj.status == 'APPROVED': - obj.approve(request.user, obj.review_notes) + obj.approve(request.user) elif obj.status == 'REJECTED': - obj.reject(request.user, obj.review_notes) + obj.reject(request.user) + elif obj.status == 'ESCALATED': + obj.escalate(request.user) super().save_model(request, obj, form, change) class PhotoSubmissionAdmin(admin.ModelAdmin): - list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'submitted_at', 'reviewed_by'] - list_filter = ['status', 'content_type', 'submitted_at'] - search_fields = ['user__username', 'caption', 'review_notes'] - readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'submitted_at'] + list_display = ['id', 'user_link', 'content_type', 'content_link', 'photo_preview', 'status', 'created_at', 'handled_by'] + list_filter = ['status', 'content_type', 'created_at'] + search_fields = ['user__username', 'caption', 'notes'] + readonly_fields = ['user', 'content_type', 'object_id', 'photo_preview', 'created_at'] def user_link(self, obj): url = reverse('admin:accounts_user_change', args=[obj.user.id]) @@ -68,9 +70,9 @@ class PhotoSubmissionAdmin(admin.ModelAdmin): def save_model(self, request, obj, form, change): if 'status' in form.changed_data: if obj.status == 'APPROVED': - obj.approve(request.user, obj.review_notes) + obj.approve(request.user, obj.notes) elif obj.status == 'REJECTED': - obj.reject(request.user, obj.review_notes) + obj.reject(request.user, obj.notes) super().save_model(request, obj, form, change) # Register with moderation site only diff --git a/moderation/migrations/0003_rename_fields_and_update_status.py b/moderation/migrations/0003_rename_fields_and_update_status.py new file mode 100644 index 00000000..9f86a541 --- /dev/null +++ b/moderation/migrations/0003_rename_fields_and_update_status.py @@ -0,0 +1,107 @@ +# Generated manually + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('moderation', '0002_editsubmission_submission_type_and_more'), + ] + + operations = [ + # EditSubmission changes + migrations.RenameField( + model_name='editsubmission', + old_name='submitted_at', + new_name='created_at', + ), + migrations.RenameField( + model_name='editsubmission', + old_name='reviewed_by', + new_name='handled_by', + ), + migrations.RenameField( + model_name='editsubmission', + old_name='reviewed_at', + new_name='handled_at', + ), + migrations.RenameField( + model_name='editsubmission', + old_name='review_notes', + new_name='notes', + ), + migrations.AlterField( + model_name='editsubmission', + name='status', + field=models.CharField( + choices=[ + ('NEW', 'New'), + ('APPROVED', 'Approved'), + ('REJECTED', 'Rejected'), + ('ESCALATED', 'Escalated'), + ], + default='NEW', + max_length=20, + ), + ), + migrations.AlterField( + model_name='editsubmission', + name='handled_by', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='handled_submissions', + to='accounts.user', + ), + ), + + # PhotoSubmission changes + migrations.RenameField( + model_name='photosubmission', + old_name='submitted_at', + new_name='created_at', + ), + migrations.RenameField( + model_name='photosubmission', + old_name='reviewed_by', + new_name='handled_by', + ), + migrations.RenameField( + model_name='photosubmission', + old_name='reviewed_at', + new_name='handled_at', + ), + migrations.RenameField( + model_name='photosubmission', + old_name='review_notes', + new_name='notes', + ), + migrations.AlterField( + model_name='photosubmission', + name='status', + field=models.CharField( + choices=[ + ('NEW', 'New'), + ('APPROVED', 'Approved'), + ('REJECTED', 'Rejected'), + ('AUTO_APPROVED', 'Auto Approved'), + ], + default='NEW', + max_length=20, + ), + ), + migrations.AlterField( + model_name='photosubmission', + name='handled_by', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='handled_photos', + to='accounts.user', + ), + ), + ] diff --git a/moderation/mixins.py b/moderation/mixins.py index 9dc8ecf1..80d53de9 100644 --- a/moderation/mixins.py +++ b/moderation/mixins.py @@ -192,8 +192,8 @@ class InlineEditMixin: context['pending_edits'] = EditSubmission.objects.filter( content_type=ContentType.objects.get_for_model(obj), object_id=obj.id, - status='PENDING' - ).select_related('user').order_by('-submitted_at') + status='NEW' + ).select_related('user').order_by('-created_at') return context class HistoryMixin: @@ -211,7 +211,7 @@ class HistoryMixin: content_type=content_type, object_id=obj.id ).exclude( - status='PENDING' - ).select_related('user', 'reviewed_by').order_by('-submitted_at') + status='NEW' + ).select_related('user', 'handled_by').order_by('-created_at') return context diff --git a/moderation/models.py b/moderation/models.py index 67440d0b..8f4c503d 100644 --- a/moderation/models.py +++ b/moderation/models.py @@ -7,10 +7,10 @@ from django.apps import apps class EditSubmission(models.Model): STATUS_CHOICES = [ - ('PENDING', 'Pending'), + ('NEW', 'New'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected'), - ('AUTO_APPROVED', 'Auto Approved'), + ('ESCALATED', 'Escalated'), ] SUBMISSION_TYPE_CHOICES = [ @@ -53,26 +53,26 @@ class EditSubmission(models.Model): status = models.CharField( max_length=20, choices=STATUS_CHOICES, - default='PENDING' + default='NEW' ) - submitted_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) # Review details - reviewed_by = models.ForeignKey( + handled_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, - related_name='reviewed_submissions' + related_name='handled_submissions' ) - reviewed_at = models.DateTimeField(null=True, blank=True) - review_notes = models.TextField( + handled_at = models.DateTimeField(null=True, blank=True) + notes = models.TextField( blank=True, help_text='Notes from the moderator about this submission' ) class Meta: - ordering = ['-submitted_at'] + ordering = ['-created_at'] indexes = [ models.Index(fields=['content_type', 'object_id']), models.Index(fields=['status']), @@ -96,12 +96,11 @@ class EditSubmission(models.Model): return resolved_data - def approve(self, moderator, notes=''): + def approve(self, user): """Approve the submission and apply the changes""" self.status = 'APPROVED' - self.reviewed_by = moderator - self.reviewed_at = timezone.now() - self.review_notes = notes + self.handled_by = user + self.handled_at = timezone.now() model_class = self.content_type.model_class() resolved_data = self._resolve_foreign_keys(self.changes) @@ -122,42 +121,23 @@ class EditSubmission(models.Model): self.save() return obj - def reject(self, moderator, notes): + def reject(self, user): """Reject the submission""" self.status = 'REJECTED' - self.reviewed_by = moderator - self.reviewed_at = timezone.now() - self.review_notes = notes + self.handled_by = user + self.handled_at = timezone.now() self.save() - def auto_approve(self): - """Auto-approve the submission (for moderators/admins)""" - self.status = 'AUTO_APPROVED' - self.reviewed_by = self.user - self.reviewed_at = timezone.now() - - model_class = self.content_type.model_class() - resolved_data = self._resolve_foreign_keys(self.changes) - - if self.submission_type == 'CREATE': - # Create new object - obj = model_class(**resolved_data) - obj.save() - # Update object_id after creation - self.object_id = obj.id - else: - # Apply changes to existing object - obj = self.content_object - for field, value in resolved_data.items(): - setattr(obj, field, value) - obj.save() - + def escalate(self, user): + """Escalate the submission to admin""" + self.status = 'ESCALATED' + self.handled_by = user + self.handled_at = timezone.now() self.save() - return obj class PhotoSubmission(models.Model): STATUS_CHOICES = [ - ('PENDING', 'Pending'), + ('NEW', 'New'), ('APPROVED', 'Approved'), ('REJECTED', 'Rejected'), ('AUTO_APPROVED', 'Auto Approved'), @@ -184,26 +164,26 @@ class PhotoSubmission(models.Model): status = models.CharField( max_length=20, choices=STATUS_CHOICES, - default='PENDING' + default='NEW' ) - submitted_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) # Review details - reviewed_by = models.ForeignKey( + handled_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, - related_name='reviewed_photos' + related_name='handled_photos' ) - reviewed_at = models.DateTimeField(null=True, blank=True) - review_notes = models.TextField( + handled_at = models.DateTimeField(null=True, blank=True) + notes = models.TextField( blank=True, help_text='Notes from the moderator about this photo submission' ) class Meta: - ordering = ['-submitted_at'] + ordering = ['-created_at'] indexes = [ models.Index(fields=['content_type', 'object_id']), models.Index(fields=['status']), @@ -217,9 +197,9 @@ class PhotoSubmission(models.Model): from media.models import Photo self.status = 'APPROVED' - self.reviewed_by = moderator - self.reviewed_at = timezone.now() - self.review_notes = notes + self.handled_by = moderator + self.handled_at = timezone.now() + self.notes = notes # Create the approved photo Photo.objects.create( @@ -236,9 +216,9 @@ class PhotoSubmission(models.Model): def reject(self, moderator, notes): """Reject the photo submission""" self.status = 'REJECTED' - self.reviewed_by = moderator - self.reviewed_at = timezone.now() - self.review_notes = notes + self.handled_by = moderator + self.handled_at = timezone.now() + self.notes = notes self.save() def auto_approve(self): @@ -246,8 +226,8 @@ class PhotoSubmission(models.Model): from media.models import Photo self.status = 'AUTO_APPROVED' - self.reviewed_by = self.user - self.reviewed_at = timezone.now() + self.handled_by = self.user + self.handled_at = timezone.now() # Create the approved photo Photo.objects.create( diff --git a/moderation/urls.py b/moderation/urls.py index b4d320b0..55706ba9 100644 --- a/moderation/urls.py +++ b/moderation/urls.py @@ -1,16 +1,11 @@ -from django.urls import path, include -from .admin import moderation_site -from .views import EditSubmissionListView, PhotoSubmissionListView +from django.urls import path +from . import views app_name = 'moderation' urlpatterns = [ - # Custom moderation views - path('submissions/', include([ - path('edits/', EditSubmissionListView.as_view(), name='edit_submissions'), - path('photos/', PhotoSubmissionListView.as_view(), name='photo_submissions'), - ])), - - # Admin site URLs - path('admin/', moderation_site.urls), + path('submissions/', views.EditSubmissionListView.as_view(), name='edit_submissions'), + path('submissions//approve/', views.approve_submission, name='approve_submission'), + path('submissions//reject/', views.reject_submission, name='reject_submission'), + path('submissions//escalate/', views.escalate_submission, name='escalate_submission'), ] diff --git a/moderation/views.py b/moderation/views.py index 9c2d6a6b..ff809879 100644 --- a/moderation/views.py +++ b/moderation/views.py @@ -1,100 +1,90 @@ from django.views.generic import ListView -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import JsonResponse +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.shortcuts import get_object_or_404 -from django.utils import timezone -from .models import EditSubmission, PhotoSubmission -from .mixins import ModeratorRequiredMixin +from django.http import HttpResponse +from django.contrib import messages +from django.db.models import Q +from .models import EditSubmission -class EditSubmissionListView(ModeratorRequiredMixin, ListView): +class ModeratorRequiredMixin(UserPassesTestMixin): + def test_func(self): + return self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + +class EditSubmissionListView(LoginRequiredMixin, ModeratorRequiredMixin, ListView): model = EditSubmission - template_name = 'moderation/admin/edit_submission_list.html' + template_name = 'moderation/edit_submissions.html' context_object_name = 'submissions' - paginate_by = 20 def get_queryset(self): - queryset = super().get_queryset().select_related( - 'user', 'reviewed_by', 'content_type' - ).order_by('-submitted_at') - - # Filter by status - status = self.request.GET.get('status') - if status: - queryset = queryset.filter(status=status) - - # Filter by submission type - submission_type = self.request.GET.get('type') - if submission_type: - queryset = queryset.filter(submission_type=submission_type) - + tab = self.request.GET.get('tab', 'new') + queryset = EditSubmission.objects.select_related('user', 'content_type') + + # Include edits by privileged users (mods, admins, superusers) in appropriate tabs + privileged_roles = ['MODERATOR', 'ADMIN', 'SUPERUSER'] + + if tab == 'new': + # Show pending submissions, oldest first + queryset = queryset.filter(status='NEW').order_by('created_at') + elif tab == 'approved': + # Show approved submissions and auto-approved edits by privileged users + queryset = queryset.filter( + Q(status='APPROVED') | + Q(user__role__in=privileged_roles, status='NEW') # Include privileged users' edits + ).order_by('-created_at') + elif tab == 'rejected': + # Show rejected submissions, newest first + queryset = queryset.filter(status='REJECTED').order_by('-created_at') + elif tab == 'escalated' and self.request.user.role in ['ADMIN', 'SUPERUSER']: + # Show escalated submissions, newest first + queryset = queryset.filter(status='ESCALATED').order_by('-created_at') + else: + # Default to new submissions if invalid tab + queryset = queryset.filter(status='NEW').order_by('created_at') + return queryset - def post(self, request, *args, **kwargs): - submission_id = request.POST.get('submission_id') - action = request.POST.get('action') - review_notes = request.POST.get('review_notes', '') - - submission = get_object_or_404(EditSubmission, id=submission_id) - - if action == 'approve': - obj = submission.approve(request.user, review_notes) - message = 'New addition approved successfully.' if submission.submission_type == 'CREATE' else 'Changes approved successfully.' - return JsonResponse({ - 'status': 'success', - 'message': message, - 'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None - }) - elif action == 'reject': - submission.reject(request.user, review_notes) - message = 'New addition rejected.' if submission.submission_type == 'CREATE' else 'Changes rejected.' - return JsonResponse({ - 'status': 'success', - 'message': message - }) - - return JsonResponse({ - 'status': 'error', - 'message': 'Invalid action.' - }, status=400) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['active_tab'] = self.request.GET.get('tab', 'new') + context['new_count'] = EditSubmission.objects.filter(status='NEW').count() + if self.request.user.role in ['ADMIN', 'SUPERUSER']: + context['escalated_count'] = EditSubmission.objects.filter(status='ESCALATED').count() + return context -class PhotoSubmissionListView(ModeratorRequiredMixin, ListView): - model = PhotoSubmission - template_name = 'moderation/admin/photo_submission_list.html' - context_object_name = 'submissions' - paginate_by = 20 + def get_template_names(self): + if self.request.htmx: + return ['moderation/partials/submission_list.html'] + return [self.template_name] - def get_queryset(self): - queryset = super().get_queryset().select_related( - 'user', 'reviewed_by', 'content_type' - ).order_by('-submitted_at') - - status = self.request.GET.get('status') - if status: - queryset = queryset.filter(status=status) - - return queryset +def approve_submission(request, submission_id): + submission = get_object_or_404(EditSubmission, id=submission_id) + + if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + submission.approve(request.user) + messages.success(request, 'Submission approved successfully') + + # Return updated submission list for current tab + view = EditSubmissionListView.as_view() + return view(request) - def post(self, request, *args, **kwargs): - submission_id = request.POST.get('submission_id') - action = request.POST.get('action') - review_notes = request.POST.get('review_notes', '') - - submission = get_object_or_404(PhotoSubmission, id=submission_id) - - if action == 'approve': - submission.approve(request.user, review_notes) - return JsonResponse({ - 'status': 'success', - 'message': 'Photo approved successfully.' - }) - elif action == 'reject': - submission.reject(request.user, review_notes) - return JsonResponse({ - 'status': 'success', - 'message': 'Photo rejected successfully.' - }) - - return JsonResponse({ - 'status': 'error', - 'message': 'Invalid action.' - }, status=400) +def reject_submission(request, submission_id): + submission = get_object_or_404(EditSubmission, id=submission_id) + + if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + submission.reject(request.user) + messages.success(request, 'Submission rejected successfully') + + # Return updated submission list for current tab + view = EditSubmissionListView.as_view() + return view(request) + +def escalate_submission(request, submission_id): + submission = get_object_or_404(EditSubmission, id=submission_id) + + if request.user.role == 'MODERATOR': + submission.escalate(request.user) + messages.success(request, 'Submission escalated to admin') + + # Return updated submission list for current tab + view = EditSubmissionListView.as_view() + return view(request) diff --git a/parks/__pycache__/admin.cpython-312.pyc b/parks/__pycache__/admin.cpython-312.pyc index bdfc28cebd15b2703b281e4a9c435ea3007993f0..7abc574b0f2de5537c5ec4ac0fd616110d5e358c 100644 GIT binary patch literal 2601 zcmb_e&2Jk;6rZto*XvE3q_NY6l#gxNhHjybREi)_`XMSPqID5S9QLx>dM5FvyIyy8 zP34?|REaA{Na>NQ)jx$xDizflDWVdmN*p2*E^cr<3o2p{KfjrI^XAR__>F(A zR4f9Y@%!-=$0X!891K4_7k0j(glrH-7!658T}n|dgoUW+7L{BKwMch$kTs@K_eX|y)tA~e2kA7EQ8OcM5mw5SpAsbQh68lVU zjrPGEtgsSIxD(3nvCQw>=W+0^0+wGNo;mv6yT{Z^oLpL+A6Xbmt0#ui=$B88JnT(4 zlTUETp;Gr*6tqDYTaHEKr(P?KLKsee6-1qoUk#Ep7QM@;^aRpHoPyN8@Wm}@B3>5U zZ;lQjkeKY-ISs!XgcFxC;uaWni>%;ktmx`Ya}B05BcN`HmF^PP?3qqkmX$r_j3KqQ zA8~1hannzOxGjw&_0w)5_4rPk3u!gs+>|rVPo>rEuv~UDQGsJ+sTp_Mspv^V@D<#y zHG{O*FN!f(72~Kf#(2^cK?kk(3t|!%rf@+^!tP3+D%IxjHn5;Rth5~sV1x9iQ+Qhc zxC`Nlf;DcZEpCSpESm*MC-i$Zgwt+i(X$0_#)2h|KJp;*bUfGa@CtHo)J?ePlRktx z;o-6@34XiFlQcnpZ2Rej#a0~g#g5-xOj{xd!#lyPV4vu*RlmIwd+pf6{G^~MZY7H< z)AdeI&aQCCbLN|ePJ6l2Yx8?asJ}=DBb;Chuan1<*LOf5jec30o`*T}JZX7e6tixK z^0?>S?)qWAGQR&TIi^O-L6|~DD+%{Sv*j%ZJY&PhXgM^k6UZ7gf9(0;%R;4(d3Ea0qt{b8r!J za1?W}^!yxL%5#7_fq@QX+!HkY@XNe^s3^hOf6EULO^KO6I1~uS0K%z`?5q1{)N`Oi zM*$plXWHv2vvl45o!RCP!)Qdkk+u*0`|`t>=zoJJU>x#y5%RH&zRTY zA_|fOcx!FpIX9?3tA|v)lXdK1vXsxsmh6rI_u3C9+2Rsz}w10`D8Z@ z!laH5q`*f&))Mtb4ym-R2v%UEr2!Pk!$j4qfImPvF=1%~sRuyzCcaGxa^K7WGx^rD zsB2wEJBmS+kFu24lGK&jf%{AIJWK=kUJzGdJ3iRSj{w_6O6eA{w}`Vv-uacx?`mb* zpqo?ky99;Z!)1DjZq^oXc!_S$5^8*BeQP};)d!^d{gr#pLvmt3^nG(z??n&E+kYB_ wp4%d`yP8F3?wx!zclyEH>0JVY?YRRsHm?0}?NM#vL2Y64)Q7*2^D6Lv0Z|@l7ytkO literal 1166 zcmbtTy=xRf6rb6z+q>K)F<)W=F~$#A#9Wa=0}@E3_z?xWZHDz`a<|FshqHTx>n;$m z)hey+5&sjLK$>G97_bU9k+ZUL-rQa^U@8ap_ujmD-|y|0TCIZA`Pf=+em4;M2A9!s z3TE(GF$ahuiaCm~k1_BDHzLzF6>n0DTaoSCk>fiU8HnNS@sX21l-Y-Pcr(xwMD2Y< z9cCVyL)l@W=(|_iJD2QB%HI8leYsQiDrex$!_p3D6vm2~dJ{$|XHUZ{Pef+}q-T(g zXGrUHAaZN!wgUD1;L#I89>SDYSZM zppr(v*6Xb#V)Zm=)bo}IIo}O;!oOHTw}ZHukT@aG_FQR-ovf}Wwvu*48R`eH)tB8; zu#ZlspLJjKr|x#2_h%kpsM>Er(D_B$MF)?hh&ggQM(tSMK zm384M3;0noU%|PFF+M>{CusF6T0X@S-P!kxdy5~eV?6uQu<_c_ diff --git a/parks/__pycache__/models.cpython-312.pyc b/parks/__pycache__/models.cpython-312.pyc index a2e154aa03d5dec77033665f41369c60d4bf9e5a..a4b34985d4d6751aa194acabeeaf71dbc1c8b8b6 100644 GIT binary patch delta 383 zcmZ2val(T4G%qg~0}v#9SjFpU&4|0l6zR%;q$TT^D zLu~Q^UNN>5rW%GUw#n;wH77Uos=+0c8JQGFCZxmY3#99j!DxRDwI*qYz@*h!s#`4KJ zV*ePcHfxLPGcndrj+Ru3iU;Y^2N4M%!VW|vgNPImkq9EVfy6C#XUAY?M;F(kI*?EX zh{yyHSs z7*h)4WJU>bpoG}uxx8v{31vp6$^Uq*CtLF+vQ+^MYhakXhVQXx6p&fO0V1M71Sg1y z*}R0`h?$Xd^L3&1Osq9Pp@PZXqSF{_Co75RGnPya68pzkzBy1_pNX+%a-*b5R2)c` zK8T115q2OV2}C4=hy)P94J2-{J39tDJG!_Q)q;f5K|}_K$OI7uAfgaN6oCj95K%n& zzoe{YDTo;cBFaESIgls@1^|OYkr{|z2_q6FyGngxtek8iy#z?!m6m1focvo_RU@2{ lQKG}F-=@>%3j>J#rHDz5(V4NMtiPtS<|_k;U1SP01OT>TSO@?B diff --git a/parks/__pycache__/urls.cpython-312.pyc b/parks/__pycache__/urls.cpython-312.pyc index 016ae9e834ce0050fd2c3c88faf0faab20b982be..883650c5859d8a1a11399638cff54267b68ad8ee 100644 GIT binary patch delta 225 zcmcc1`I<}PG%qg~0}zz&S56maW?*;>;=lkul<}F5X`{v(MwV2TEV+rx^o_;VaI6NZ z1py|8RK^r>Ajy;>ks`T9YBdu?97d;eMhR3(YsyUi#&|(i(55)2G~EhB*y*RHWR~dP z;w(rk%8m!ICm&@BV-%b$!(7P~T9A@hk{XtoT0Xg-Igd*bXe1*L7r&a!&ay!A0;kN3 p)a!Ca7v+qu%UNBNv$`y2bHMrvi~r;$EPC9+T>MOp+(q(0^#IPZH@W}- delta 133 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 ajAr@NBL>PIy`<~a^rb-)CYG>YY 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