From 6adfd8ab901770475b8b5fe7fb201e0d184cbb78 Mon Sep 17 00:00:00 2001 From: Lakshya-2440 Date: Mon, 23 Feb 2026 10:26:28 +0530 Subject: [PATCH 1/2] Add virtual lab playground and configuration --- .gitignore | 13 + fix_urls.py | 22 + image.png | Bin 0 -> 5111 bytes manage.py | 22 + poetry.lock | 1935 ++++ pyproject.toml | 66 + remove_navbar.py | 19 + web/__init__.py | 0 web/settings.py | 505 + web/static/img/image.png | Bin 0 -> 5111 bytes web/templates/base.html | 571 ++ web/templates/index.html | 780 ++ web/urls.py | 16 + web/views.py | 8846 +++++++++++++++++ web/virtual_lab/__init__.py | 0 web/virtual_lab/apps.py | 10 + web/virtual_lab/css/virtual_lab.css | 0 web/virtual_lab/models.py | 0 .../virtual_lab/js/chemistry/ph_indicator.js | 138 + .../virtual_lab/js/chemistry/precipitation.js | 134 + .../virtual_lab/js/chemistry/reaction_rate.js | 82 + .../virtual_lab/js/chemistry/solubility.js | 73 + .../virtual_lab/js/chemistry/titration.js | 117 + .../static/virtual_lab/js/code_editor.js | 53 + .../static/virtual_lab/js/common.js | 41 + .../js/physics_electrical_circuit.js | 404 + .../static/virtual_lab/js/physics_inclined.js | 497 + .../virtual_lab/js/physics_mass_spring.js | 363 + .../static/virtual_lab/js/physics_pendulum.js | 396 + .../virtual_lab/js/physics_projectile.js | 412 + .../virtual_lab/chemistry/index.html | 58 + .../virtual_lab/chemistry/ph_indicator.html | 58 + .../virtual_lab/chemistry/precipitation.html | 56 + .../virtual_lab/chemistry/reaction_rate.html | 54 + .../virtual_lab/chemistry/solubility.html | 53 + .../virtual_lab/chemistry/titration.html | 78 + .../virtual_lab/code_editor/code_editor.html | 82 + .../templates/virtual_lab/home.html | 51 + .../templates/virtual_lab/layout.html | 77 + .../virtual_lab/physics/circuit.html | 157 + .../virtual_lab/physics/inclined.html | 179 + .../virtual_lab/physics/mass_spring.html | 178 + .../virtual_lab/physics/pendulum.html | 127 + .../virtual_lab/physics/projectile.html | 158 + web/virtual_lab/tests.py | 0 web/virtual_lab/urls.py | 37 + web/virtual_lab/views.py | 136 + web/wsgi.py | 5 + 48 files changed, 17059 insertions(+) create mode 100644 .gitignore create mode 100644 fix_urls.py create mode 100644 image.png create mode 100644 manage.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 remove_navbar.py create mode 100644 web/__init__.py create mode 100644 web/settings.py create mode 100644 web/static/img/image.png create mode 100644 web/templates/base.html create mode 100644 web/templates/index.html create mode 100644 web/urls.py create mode 100644 web/views.py create mode 100644 web/virtual_lab/__init__.py create mode 100644 web/virtual_lab/apps.py create mode 100644 web/virtual_lab/css/virtual_lab.css create mode 100644 web/virtual_lab/models.py create mode 100644 web/virtual_lab/static/virtual_lab/js/chemistry/ph_indicator.js create mode 100644 web/virtual_lab/static/virtual_lab/js/chemistry/precipitation.js create mode 100644 web/virtual_lab/static/virtual_lab/js/chemistry/reaction_rate.js create mode 100644 web/virtual_lab/static/virtual_lab/js/chemistry/solubility.js create mode 100644 web/virtual_lab/static/virtual_lab/js/chemistry/titration.js create mode 100644 web/virtual_lab/static/virtual_lab/js/code_editor.js create mode 100644 web/virtual_lab/static/virtual_lab/js/common.js create mode 100644 web/virtual_lab/static/virtual_lab/js/physics_electrical_circuit.js create mode 100644 web/virtual_lab/static/virtual_lab/js/physics_inclined.js create mode 100644 web/virtual_lab/static/virtual_lab/js/physics_mass_spring.js create mode 100644 web/virtual_lab/static/virtual_lab/js/physics_pendulum.js create mode 100644 web/virtual_lab/static/virtual_lab/js/physics_projectile.js create mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/index.html create mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/ph_indicator.html create mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/precipitation.html create mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/reaction_rate.html create mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/solubility.html create mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/titration.html create mode 100644 web/virtual_lab/templates/virtual_lab/code_editor/code_editor.html create mode 100644 web/virtual_lab/templates/virtual_lab/home.html create mode 100644 web/virtual_lab/templates/virtual_lab/layout.html create mode 100644 web/virtual_lab/templates/virtual_lab/physics/circuit.html create mode 100644 web/virtual_lab/templates/virtual_lab/physics/inclined.html create mode 100644 web/virtual_lab/templates/virtual_lab/physics/mass_spring.html create mode 100644 web/virtual_lab/templates/virtual_lab/physics/pendulum.html create mode 100644 web/virtual_lab/templates/virtual_lab/physics/projectile.html create mode 100644 web/virtual_lab/tests.py create mode 100644 web/virtual_lab/urls.py create mode 100644 web/virtual_lab/views.py create mode 100644 web/wsgi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62334fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +db.sqlite3 +*.pyc +/staticfiles +.vscode/settings.json +.vscode/launch.json +venv/ +/media +.env* +backup.json +.notes +ansible/inventory.yml +education-website-*.json +*.sql diff --git a/fix_urls.py b/fix_urls.py new file mode 100644 index 0000000..1b71997 --- /dev/null +++ b/fix_urls.py @@ -0,0 +1,22 @@ +import re +import os + +filepath = "web/templates/base.html" +with open(filepath, "r") as f: + content = f.read() + +# Find all {% url 'something' ... %} +# We'll use a regex that matches the whole tag +pattern = r"{%\s*url\s+['\"]([^'\"]+)['\"][^%]*%}" + +def replace_url(match): + url_target = match.group(1) + if url_target.startswith("virtual_lab"): + return match.group(0) # Keep virtual lab urls + return "#" + +new_content = re.sub(pattern, replace_url, content) + +with open(filepath, "w") as f: + f.write(new_content) +print("Replaced broken urls in base.html") diff --git a/image.png b/image.png new file mode 100644 index 0000000000000000000000000000000000000000..bdb6de16076893714d5fc8f3a81b934bd41ab8ce GIT binary patch literal 5111 zcmVPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91WS|281ONa40RR91X8-^I0B9SekpKV_VM#V5;Hk@-Jfg zn-)}6RS8rmTT0E!7^S{#n3r9;bSeFvcc%MHcAc!*)zw*9Q&ZD6%p;>NSVNUcRQ#wE zo0u4(o}OM*Qc_Z!ay#X_+qZ9qr=_JmQKfvcnHQA<^sIj3Q1q1{j`*GvY{reA! zr~Q%yNDcAZu@i$gHa0fKTYR?S{DOzLxw!>LN5|A`_VNAn zrAJ=hvMB*kKnN)0(AU?;n>PF4v~(Ws?Civ0VORV2?mw_+;lhQ6Z06;4+8_ZsKnN%r z8XDNr(h_gnv<2TyO~cQh*JDmhT-E2Ff3eTq-QE7R@K}-n6CebXUxBg0ettVJKO+-Y zR6N1S*KZbn^2w(k&6zXDk)>??c9R_r5Fs$+Y-weMcL3jH=iI|jpVs2|goHwW|6PZ? zyu8eRyAo7z$)y2A2o53tt-jlE!NWp|{3D~H3HcvUaMpgC$;kqU5Eugk1MIWa7xVZT zxV@ueFf=s0ckjLfyI+&P9RDSG03pB-^0%_Grn<{%Y3aDGt`0|YVk<&I{(H~-`SZug zku{|~N~=0K`0?MooSi558yOmugI=joS-A-g-V>6VmWJ--WXt?YP`RMo+y^K&HWodt zeTEh-T4ee=clTvernxP1uy@q$>l0L~ol54$t(1M$HBa%ih{)&bH*7kwY}qm^nI$B} zgYwo8coo$f6Frj}vvXGfh7mtS{;_fK54Ugk|7hjPm8Mc;ZDgYoK)QQ+FmW9n9r(w~ zSMb_(>%Vk%b~a%%XKVl>1jpFe1aI@(jvp2l;il$h91#&!y=lwV!)%sEVQGs3LZJdb zot<4cG%UO)D0uJxI668`P}s{v5@Hh&cn!rdMn*>1ciVPq9Mjg;KKN5;SodG|9}G}w z@}~oY05do^i0kX?aa;oT>Fzy${fCp2(_|5O%6-W;AOsX4FxECU0E{2z@%Xs5_8E?h z=9GQ**%v{xX1!-8cR>`L023g>+A;_i_4N%zeNpM_=XVU$7ZVkc2W0_5Kp_X|m04Tc z;9UWM)Bv-ryaIE%$$1A39Z{`b*>Hdefg$JmhDIEI_1bf9@4x)h)6-L~QL?fD5duTb z1ek!pAe@_*kD+%4f42AnUtiyS8#it=hVYSf(f}d^2L@yKhf6;a^8Z3s@*hb+K;l%b zF*Y&5+jscm!lGi_)YOcxUX7^Qlm&prxjD7Qls? zyNHVBRQUM#{$s&{1!8NAssZvVFdEp##uf(!1ycjeva)iVz)gCvfB!+{^vV+5x7j0wM&4oJn_ickmu+YMGFKR8&m<=FL8PK<&@o3?>_Z z2*GIwj``R3KM?W{nK*IckRFUW03rm3aE$-XUAUyt;n%9RY}vXW!WHk>1cU%X znzGimHhlS~(Ds0!;4gq<-Xx_^CqN*(6#1K)nNhm3%&crOA2V?I@=vXx_V<&O{Asnl zVC~`V`*@K&i0v98Y`%nN zQe#N3jErRtANd3WSX3_ua;jEm(p|QJURm&-y*M{F4?iv|!*TK4d(d6pHFxe@o8cvo z(1_HKmor9c49xf;9UUDsbH+^M;5Z&VdGZ7`H8mmVLcbhcrSCgCI#33mkD{WYQAcMd z^7LG4>#^A5Lnp^KJTXZgUlS8k;Fv&){2O894^zvPp!VOZc7PCY$N;msr3HU`{sNve=Uoh(_M)Jog;w=z z(v+E7Sb(lN4=G}Masx2_2rrX=w zQ%X!?zEo9JjlcZ+(XWTCEvg2H5F7%`Gca5@dFnKNXZm!tCVyKyJN%@w8lO6Q{;DV- znxZ$VehpP0*;renpuhlhk(W8u!9PI58Hz|oRoCFvd z8X~`KThW@;D^d8>2z24Xd368YJv1=TFZMY}15chhgVuX{NgADs2ZrHN#TSh{qJUfm zK#EFAMe{S{x}>ZO6+J9OlO~JT-jk-x%*+h!^xuw_FaHp1vf|N20?hq;sJ~x6V2q3m zk%fiDkbJRuGh%hTqP!gCgRsT9fq_938yAa6n=6SUz>KrAqkxHvk3+|f{}WA{=0*)T zC3#*I;*1y|ckbLl&>tUG28qyQfo<5Z@kApKU~Fw{Q1I>`^z+S|=zqUDhFn}GA?S9B zMpMb_5zAp%SeQi3Nmh0?0&`ZGg=Y=qPk_nH$^`QN7P(AvRcZ1c5kSa4K8?qhNXWf{ zJ`^1lB~e^)kqG&3_uGb2@7_fxPn|+DXU$T{?(&F`dJ>n5z*JS7{Dy?319-5$kWxSn z36S!JG-VbR<|ts-PPAhAaufpw*%vQdpp3Jb$Ui!o1M@T`ue@p2%$aEQsui#Jv*N+# zNWHhW7yH5j8*N=Zi4VJR<8WSXu5iGvu=9UZGSBdTg?x_n^z@|ClabZJOb$SiC@nG} zH{v7pmX;QD^XAV|{gE0Pn@~#19jTs?Br>TncD6R?{rBHTIxu0T3Qp-9a_4pms;zr2 zb=l*&38=qMx@iQdU!npJC>0QBB2e;;>x{+L(bk3@5X@H5xcvMAXzO!nLPnRDQUR%{ zu0|OdnT!%#@#hbbg*m->a6@AwN>Ar8K08_hlnO{;VIg{4UPdcBQZs@A1JNXxw`s#k z?TUj@`)GGc1tc;e67}`b>z3?ntkLflEC5~9a^_|WR4q>`Dn>OXN(1C+O$|!r(Kk7y zc7WMGC1_Zn3C)%ntNB{)_OXo}S3ifZlewySoQPMMl1eJK8Rk0>~{;frEtwt?cw* zgzV}0{*W+O<%_0HoifBv^yV&XF3>kh2Z>4nogBpu(Q4 z0zw2A7az}PFzx)t8|dw~-l7!`?1P0CFIh|*Mm+DPrlLwP$R2Hu)d{M$)>afBpTH7uq9r|M&76gt-*l!GS5I#*`sLquX~WdflNCU+vvWY#)J!W6VnGNSx6p<$dBSQV zG-Jk4eV1rlvIFVBz<{Wi+FmOh5HdQCjEY2LoIvL$!a#1;?AdfdV*SW?V+;%=>0D)X z4dOG*M$*MsWk327%8}!e;$oD`Fc>6LwQJU_mMQhApx1I3XV3U-0+^=~LkM+u3I~Kt zM71*XuFOqM(SrH&Ws(_D(|dR@tK?&2;}EfmRClLvKq4X{7~Atnu9MIVm@}8fk=o_C zbSa}nN-3l?`r zCLRC!%K0<=w*@U6K|iX7xs~?z zHi?&E%UgE%UJ8Qk%^z~(j%43LU*};T_TtBRWyfFr=z3m1R81U9Xoa)V`EclyFoUz0CY7(Za|2X zQ@C8gjq6E#g!Q7ETbs&(TO4$Bb!^myV+bIRODm3l|NVsq*z$v)mzNv;sI;^k?8Y2m zL8y(-mMzHC)Ere*Q~<}cE37GF4G`g9KRcV(5zC3rgtm)=v1S>JE?r=AOLH>sp~_AO zNJ`4j)YKzzV@G~oUMB1+#DU#X%LIZx7cZ~1=ESH^f2;w5ZC_!#5BY(i1B6vMZ9Z_o zAna#e()iD#PZOgkEw5t zfR0UOj-lfaraUOzA4dKMc>GM2B>&+65fz4q$C3l5&YY%G0r)~;Pktq7~BsiyY&0^$@tGUQAL$dCvlbrTpEXc%@iA|N%5*A3t(o&2TY5OFfH zva&Mt^70P&=ETWv*yv!8e8Kvo0|&9Cr4{uh1$cG{&(Lv*xb#OP?Bwawhxr*<)yf?C zD+&-%fi3j-MQ2K&hzcDJ?BY&(6whEU@|P1HkD^yo-Y4v0U~@C zs9V}{AKd3l^BqG`F-B+`jp<`Y*f(wf~Wf%KS(PMpNu>ki z-<_D4kO~b~YHVVqY7L06Aeot&ZBbE?>HYm=Q1Os(oNx9U{jv{}tiK-GHVlz4# z0>3$V`qRwp9I@oDN~At2nk;dDoJ4NIFD`Gtbt%7~utk5YzJrOeiKUj-&|<4n4H=p< zaV1mZFj#=vl@K3y7Yvo-+B>^zVHay4e;rHV0P{5=4", markers = "python_version < \"3.11\""} + +[package.extras] +tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_full_version < \"3.11.3\"" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "bleach" +version = "6.2.0" +description = "An easy safelist-based HTML-sanitizing tool." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e"}, + {file = "bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f"}, +] + +[package.dependencies] +webencodings = "*" + +[package.extras] +css = ["tinycss2 (>=1.1.0,<1.5)"] + +[[package]] +name = "cachetools" +version = "5.5.1" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"}, + {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"}, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "channels" +version = "4.3.1" +description = "Brings async, event-driven capabilities to Django." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "channels-4.3.1-py3-none-any.whl", hash = "sha256:b091d4b26f91d807de3e84aead7ba785314f27eaf5bac31dd51b1c956b883859"}, + {file = "channels-4.3.1.tar.gz", hash = "sha256:97413ffd674542db08e16a9ef09cd86ec0113e5f8125fbd33cf0854adcf27cdb"}, +] + +[package.dependencies] +asgiref = ">=3.9.0,<4" +Django = ">=4.2" + +[package.extras] +daphne = ["daphne (>=4.0.0)"] +tests = ["async-timeout", "coverage (>=4.5,<5.0)", "pytest", "pytest-asyncio", "pytest-django", "selenium"] + +[[package]] +name = "channels-redis" +version = "4.3.0" +description = "Redis-backed ASGI channel layer implementation" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5"}, + {file = "channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2"}, +] + +[package.dependencies] +asgiref = ">=3.9.1,<4" +channels = ">=4.2.2" +msgpack = ">=1.0,<2.0" +redis = ">=4.6" + +[package.extras] +cryptography = ["cryptography (>=1.3.0)"] +tests = ["async-timeout", "cryptography (>=1.3.0)", "pytest", "pytest-asyncio", "pytest-timeout"] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cryptography" +version = "44.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["main"] +files = [ + {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"}, + {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"}, + {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"}, + {file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"}, + {file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"}, + {file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"}, + {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"}, + {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"}, + {file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"}, + {file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"}, + {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"}, + {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"}, + {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"}, + {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, + {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] +pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "cssbeautifier" +version = "1.15.3" +description = "CSS unobfuscator and beautifier." +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "cssbeautifier-1.15.3-py3-none-any.whl", hash = "sha256:0dcaf5ce197743a79b3a160b84ea58fcbd9e3e767c96df1171e428125b16d410"}, + {file = "cssbeautifier-1.15.3.tar.gz", hash = "sha256:406b04d09e7d62c0be084fbfa2cba5126fe37359ea0d8d9f7b963a6354fc8303"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +jsbeautifier = "*" +six = ">=1.13.0" + +[[package]] +name = "distlib" +version = "0.3.9" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + +[[package]] +name = "django" +version = "5.1.15" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432"}, + {file = "django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947"}, +] + +[package.dependencies] +asgiref = ">=3.8.1,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-allauth" +version = "65.4.1" +description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "django_allauth-65.4.1.tar.gz", hash = "sha256:60b32aef7dbbcc213319aa4fd8f570e985266ea1162ae6ef7a26a24efca85c8c"}, +] + +[package.dependencies] +asgiref = ">=3.8.1" +Django = ">=4.2.16" + +[package.extras] +mfa = ["fido2 (>=1.1.2)", "qrcode (>=7.0.0)"] +openid = ["python3-openid (>=3.0.8)"] +saml = ["python3-saml (>=1.15.0,<2.0.0)"] +socialaccount = ["pyjwt[crypto] (>=1.7)", "requests (>=2.0.0)", "requests-oauthlib (>=0.3.0)"] +steam = ["python3-openid (>=3.0.8)"] + +[[package]] +name = "django-browser-reload" +version = "1.18.0" +description = "Automatically reload your browser in development." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "django_browser_reload-1.18.0-py3-none-any.whl", hash = "sha256:ed4cc2fb83c3bf6c30b54107a1a6736c0b896e62e4eba666d81005b9f2ecf6f8"}, + {file = "django_browser_reload-1.18.0.tar.gz", hash = "sha256:c5f0b134723cbf2a0dc9ae1ee1d38e42db28fe23c74cdee613ba3ef286d04735"}, +] + +[package.dependencies] +asgiref = ">=3.6" +django = ">=4.2" + +[[package]] +name = "django-environ" +version = "0.11.2" +description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." +optional = false +python-versions = ">=3.6,<4" +groups = ["main"] +files = [ + {file = "django-environ-0.11.2.tar.gz", hash = "sha256:f32a87aa0899894c27d4e1776fa6b477e8164ed7f6b3e410a62a6d72caaf64be"}, + {file = "django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05"}, +] + +[package.extras] +develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.dev0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] +docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] +testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"] + +[[package]] +name = "django-markdownx" +version = "4.0.7" +description = "A comprehensive Markdown editor built for Django." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "django-markdownx-4.0.7.tar.gz", hash = "sha256:38aa331c2ca0bee218b77f462361b5393e4727962bc6021939c09048363cb6ea"}, + {file = "django_markdownx-4.0.7-py2.py3-none-any.whl", hash = "sha256:c1975ae3053481d4c111abd38997a5b5bb89235a1e3215f995d835942925fe7b"}, +] + +[package.dependencies] +Django = "*" +Markdown = "*" +Pillow = "*" + +[[package]] +name = "django-ranged-response" +version = "0.2.0" +description = "Modified Django FileResponse that adds Content-Range headers." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "django-ranged-response-0.2.0.tar.gz", hash = "sha256:f71fff352a37316b9bead717fc76e4ddd6c9b99c4680cdf4783b9755af1cf985"}, +] + +[package.dependencies] +django = "*" + +[[package]] +name = "django-simple-captcha" +version = "0.5.20" +description = "A very simple, yet powerful, Django captcha application" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "django-simple-captcha-0.5.20.tar.gz", hash = "sha256:20273009a7beb44297e9f6c7a6bd21ada3d2fa93c314d2f6bf5e394ceeb6a297"}, + {file = "django_simple_captcha-0.5.20-py2.py3-none-any.whl", hash = "sha256:3359cb033c489eae6544a80ad92517db3d35b3b328b3b427393399c3d7f55275"}, +] + +[package.dependencies] +Django = ">=3.2" +django-ranged-response = "0.2.0" +Pillow = ">=6.2.0" + +[package.extras] +test = ["testfixtures"] + +[[package]] +name = "django-storages" +version = "1.14.4" +description = "Support for many storage backends in Django" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "django-storages-1.14.4.tar.gz", hash = "sha256:69aca94d26e6714d14ad63f33d13619e697508ee33ede184e462ed766dc2a73f"}, + {file = "django_storages-1.14.4-py3-none-any.whl", hash = "sha256:d61930acb4a25e3aebebc6addaf946a3b1df31c803a6bf1af2f31c9047febaa3"}, +] + +[package.dependencies] +Django = ">=3.2" + +[package.extras] +azure = ["azure-core (>=1.13)", "azure-storage-blob (>=12)"] +boto3 = ["boto3 (>=1.4.4)"] +dropbox = ["dropbox (>=7.2.1)"] +google = ["google-cloud-storage (>=1.27)"] +libcloud = ["apache-libcloud"] +s3 = ["boto3 (>=1.4.4)"] +sftp = ["paramiko (>=1.15)"] + +[[package]] +name = "djlint" +version = "1.36.4" +description = "HTML Template Linter and Formatter" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "djlint-1.36.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2dfb60883ceb92465201bfd392291a7597c6752baede6fbb6f1980cac8d6c5c"}, + {file = "djlint-1.36.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc6a1320c0030244b530ac200642f883d3daa451a115920ef3d56d08b644292"}, + {file = "djlint-1.36.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3164a048c7bb0baf042387b1e33f9bbbf99d90d1337bb4c3d66eb0f96f5400a1"}, + {file = "djlint-1.36.4-cp310-cp310-win_amd64.whl", hash = "sha256:3196d5277da5934962d67ad6c33a948ba77a7b6eadf064648bef6ee5f216b03c"}, + {file = "djlint-1.36.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d68da0ed10ee9ca1e32e225cbb8e9b98bf7e6f8b48a8e4836117b6605b88cc7"}, + {file = "djlint-1.36.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c0478d5392247f1e6ee29220bbdbf7fb4e1bc0e7e83d291fda6fb926c1787ba7"}, + {file = "djlint-1.36.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:962f7b83aee166e499eff916d631c6dde7f1447d7610785a60ed2a75a5763483"}, + {file = "djlint-1.36.4-cp311-cp311-win_amd64.whl", hash = "sha256:53cbc450aa425c832f09bc453b8a94a039d147b096740df54a3547fada77ed08"}, + {file = "djlint-1.36.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff9faffd7d43ac20467493fa71d5355b5b330a00ade1c4d1e859022f4195223b"}, + {file = "djlint-1.36.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:79489e262b5ac23a8dfb7ca37f1eea979674cfc2d2644f7061d95bea12c38f7e"}, + {file = "djlint-1.36.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e58c5fa8c6477144a0be0a87273706a059e6dd0d6efae01146ae8c29cdfca675"}, + {file = "djlint-1.36.4-cp312-cp312-win_amd64.whl", hash = "sha256:bb6903777bf3124f5efedcddf1f4716aef097a7ec4223fc0fa54b865829a6e08"}, + {file = "djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2"}, + {file = "djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835"}, + {file = "djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f"}, + {file = "djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4"}, + {file = "djlint-1.36.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:89678661888c03d7bc6cadd75af69db29962b5ecbf93a81518262f5c48329f04"}, + {file = "djlint-1.36.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b01a98df3e1ab89a552793590875bc6e954cad661a9304057db75363d519fa0"}, + {file = "djlint-1.36.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dabbb4f7b93223d471d09ae34ed515fef98b2233cbca2449ad117416c44b1351"}, + {file = "djlint-1.36.4-cp39-cp39-win_amd64.whl", hash = "sha256:7a483390d17e44df5bc23dcea29bdf6b63f3ed8b4731d844773a4829af4f5e0b"}, + {file = "djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd"}, + {file = "djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1"}, +] + +[package.dependencies] +click = ">=8.0.1" +colorama = ">=0.4.4" +cssbeautifier = ">=1.14.4" +jsbeautifier = ">=1.14.4" +json5 = ">=0.9.11" +pathspec = ">=0.12" +pyyaml = ">=6" +regex = ">=2023" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} +tqdm = ">=4.62.2" +typing-extensions = {version = ">=3.6.6", markers = "python_version < \"3.11\""} + +[[package]] +name = "editorconfig" +version = "0.17.0" +description = "EditorConfig File Locator and Interpreter for Python" +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "EditorConfig-0.17.0-py3-none-any.whl", hash = "sha256:fe491719c5f65959ec00b167d07740e7ffec9a3f362038c72b289330b9991dfc"}, + {file = "editorconfig-0.17.0.tar.gz", hash = "sha256:8739052279699840065d3a9f5c125d7d5a98daeefe53b0e5274261d77cb49aa2"}, +] + +[[package]] +name = "filelock" +version = "3.17.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338"}, + {file = "filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] + +[[package]] +name = "google-api-core" +version = "2.24.1" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_api_core-2.24.1-py3-none-any.whl", hash = "sha256:bc78d608f5a5bf853b80bd70a795f703294de656c096c0968320830a4bc280f1"}, + {file = "google_api_core-2.24.1.tar.gz", hash = "sha256:f8b36f5456ab0dd99a1b693a40a31d1e7757beea380ad1b38faaf8941eae9d8a"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +proto-plus = [ + {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, +] +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\""] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-api-python-client" +version = "2.161.0" +description = "Google API Client Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_api_python_client-2.161.0-py2.py3-none-any.whl", hash = "sha256:9476a5a4f200bae368140453df40f9cda36be53fa7d0e9a9aac4cdb859a26448"}, + {file = "google_api_python_client-2.161.0.tar.gz", hash = "sha256:324c0cce73e9ea0a0d2afd5937e01b7c2d6a4d7e2579cdb6c384f9699d6c9f37"}, +] + +[package.dependencies] +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +google-auth-httplib2 = ">=0.2.0,<1.0.0" +httplib2 = ">=0.19.0,<1.dev0" +uritemplate = ">=3.0.1,<5" + +[[package]] +name = "google-auth" +version = "2.38.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a"}, + {file = "google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +description = "Google Authentication Library: httplib2 transport" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, + {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, +] + +[package.dependencies] +google-auth = "*" +httplib2 = ">=0.19.0" + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.1" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "google_auth_oauthlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:2d58a27262d55aa1b87678c3ba7142a080098cbc2024f903c62355deb235d91f"}, + {file = "google_auth_oauthlib-1.2.1.tar.gz", hash = "sha256:afd0cad092a2eaa53cd8e8298557d6de1034c6cb4a740500b5357b648af97263"}, +] + +[package.dependencies] +google-auth = ">=2.15.0" +requests-oauthlib = ">=0.7.0" + +[package.extras] +tool = ["click (>=6.0.0)"] + +[[package]] +name = "googleapis-common-protos" +version = "1.67.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "googleapis_common_protos-1.67.0-py2.py3-none-any.whl", hash = "sha256:579de760800d13616f51cf8be00c876f00a9f146d3e6510e19d1f4111758b741"}, + {file = "googleapis_common_protos-1.67.0.tar.gz", hash = "sha256:21398025365f138be356d5923e9168737d94d46a72aefee4a6110a1f23463c86"}, +] + +[package.dependencies] +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httplib2" +version = "0.22.0" +description = "A comprehensive HTTP client library." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, + {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, +] + +[package.dependencies] +pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} + +[[package]] +name = "icalendar" +version = "5.0.13" +description = "iCalendar parser/generator" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "icalendar-5.0.13-py3-none-any.whl", hash = "sha256:5ded5415e2e1edef5ab230024a75878a7a81d518a3b1ae4f34bf20b173c84dc2"}, + {file = "icalendar-5.0.13.tar.gz", hash = "sha256:92799fde8cce0b61daa8383593836d1e19136e504fa1671f471f98be9b029706"}, +] + +[package.dependencies] +python-dateutil = "*" +pytz = "*" + +[[package]] +name = "identify" +version = "2.6.7" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0"}, + {file = "identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "jsbeautifier" +version = "1.15.3" +description = "JavaScript unobfuscator and beautifier." +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "jsbeautifier-1.15.3-py3-none-any.whl", hash = "sha256:b207a15ab7529eee4a35ae7790e9ec4e32a2b5026d51e2d0386c3a65e6ecfc91"}, + {file = "jsbeautifier-1.15.3.tar.gz", hash = "sha256:5f1baf3d4ca6a615bb5417ee861b34b77609eeb12875555f8bbfabd9bf2f3457"}, +] + +[package.dependencies] +editorconfig = ">=0.12.2" +six = ">=1.13.0" + +[[package]] +name = "json5" +version = "0.10.0" +description = "A Python implementation of the JSON5 data format." +optional = false +python-versions = ">=3.8.0" +groups = ["main", "dev"] +files = [ + {file = "json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa"}, + {file = "json5-0.10.0.tar.gz", hash = "sha256:e66941c8f0a02026943c52c2eb34ebeb2a6f819a0be05920a6f5243cd30fd559"}, +] + +[package.extras] +dev = ["build (==1.2.2.post1)", "coverage (==7.5.3)", "mypy (==1.13.0)", "pip (==24.3.1)", "pylint (==3.2.3)", "ruff (==0.7.3)", "twine (==5.1.1)", "uv (==0.5.1)"] + +[[package]] +name = "markdown" +version = "3.7" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, + {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, +] + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] + +[[package]] +name = "msgpack" +version = "1.1.1" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed"}, + {file = "msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8"}, + {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2"}, + {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4"}, + {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0"}, + {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26"}, + {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75"}, + {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338"}, + {file = "msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd"}, + {file = "msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8"}, + {file = "msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558"}, + {file = "msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d"}, + {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0"}, + {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f"}, + {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704"}, + {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2"}, + {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2"}, + {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752"}, + {file = "msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295"}, + {file = "msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458"}, + {file = "msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238"}, + {file = "msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a"}, + {file = "msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c"}, + {file = "msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4"}, + {file = "msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0"}, + {file = "msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9"}, + {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8"}, + {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a"}, + {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac"}, + {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b"}, + {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7"}, + {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5"}, + {file = "msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323"}, + {file = "msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69"}, + {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bba1be28247e68994355e028dcd668316db30c1f758d3241a7b903ac78dcd285"}, + {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f93dcddb243159c9e4109c9750ba5b335ab8d48d9522c5308cd05d7e3ce600"}, + {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fbbc0b906a24038c9958a1ba7ae0918ad35b06cb449d398b76a7d08470b0ed9"}, + {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:61e35a55a546a1690d9d09effaa436c25ae6130573b6ee9829c37ef0f18d5e78"}, + {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1abfc6e949b352dadf4bce0eb78023212ec5ac42f6abfd469ce91d783c149c2a"}, + {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:996f2609ddf0142daba4cefd767d6db26958aac8439ee41db9cc0db9f4c4c3a6"}, + {file = "msgpack-1.1.1-cp38-cp38-win32.whl", hash = "sha256:4d3237b224b930d58e9d83c81c0dba7aacc20fcc2f89c1e5423aa0529a4cd142"}, + {file = "msgpack-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:da8f41e602574ece93dbbda1fab24650d6bf2a24089f9e9dbb4f5730ec1e58ad"}, + {file = "msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b"}, + {file = "msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232"}, + {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf"}, + {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf"}, + {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90"}, + {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1"}, + {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88"}, + {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478"}, + {file = "msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57"}, + {file = "msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084"}, + {file = "msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd"}, +] + +[[package]] +name = "mysqlclient" +version = "2.2.7" +description = "Python interface to MySQL" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "mysqlclient-2.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:2e3c11f7625029d7276ca506f8960a7fd3c5a0a0122c9e7404e6a8fe961b3d22"}, + {file = "mysqlclient-2.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687"}, + {file = "mysqlclient-2.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:4b4c0200890837fc64014cc938ef2273252ab544c1b12a6c1d674c23943f3f2e"}, + {file = "mysqlclient-2.2.7-cp313-cp313-win_amd64.whl", hash = "sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5"}, + {file = "mysqlclient-2.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:199dab53a224357dd0cb4d78ca0e54018f9cee9bf9ec68d72db50e0a23569076"}, + {file = "mysqlclient-2.2.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585"}, + {file = "mysqlclient-2.2.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069"}, + {file = "mysqlclient-2.2.7.tar.gz", hash = "sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "oauth2client" +version = "4.1.3" +description = "OAuth 2.0 client library" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "oauth2client-4.1.3-py2.py3-none-any.whl", hash = "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac"}, + {file = "oauth2client-4.1.3.tar.gz", hash = "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6"}, +] + +[package.dependencies] +httplib2 = ">=0.9.1" +pyasn1 = ">=0.1.7" +pyasn1-modules = ">=0.0.5" +rsa = ">=3.1.4" +six = ">=1.6.1" + +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pillow" +version = "12.1.1" +description = "Python Imaging Library (fork)" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, + {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, + {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, + {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, + {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, + {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, + {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, + {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, + {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, + {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, + {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, + {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, + {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, + {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, + {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, + {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, + {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, + {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, + {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, + {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, + {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, + {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, + {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, + {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, + {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, + {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, + {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, + {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, + {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, + {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, + {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, + {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, + {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, + {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, + {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, + {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, + {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, + {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, + {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, + {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, + {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, + {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, + {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, + {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, + {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, + {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, + {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + +[[package]] +name = "pre-commit" +version = "3.8.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, + {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "proto-plus" +version = "1.26.0" +description = "Beautiful, Pythonic protocol buffers" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "proto_plus-1.26.0-py3-none-any.whl", hash = "sha256:bf2dfaa3da281fc3187d12d224c707cb57214fb2c22ba854eb0c105a3fb2d4d7"}, + {file = "proto_plus-1.26.0.tar.gz", hash = "sha256:6e93d5f5ca267b54300880fff156b6a3386b3fa3f43b1da62e680fc0c586ef22"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<6.0.0dev" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "5.29.3" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888"}, + {file = "protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a"}, + {file = "protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e"}, + {file = "protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84"}, + {file = "protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f"}, + {file = "protobuf-5.29.3-cp38-cp38-win32.whl", hash = "sha256:84a57163a0ccef3f96e4b6a20516cedcf5bb3a95a657131c5c3ac62200d23252"}, + {file = "protobuf-5.29.3-cp38-cp38-win_amd64.whl", hash = "sha256:b89c115d877892a512f79a8114564fb435943b59067615894c3b13cd3e1fa107"}, + {file = "protobuf-5.29.3-cp39-cp39-win32.whl", hash = "sha256:0eb32bfa5219fc8d4111803e9a690658aa2e6366384fd0851064b963b6d1f2a7"}, + {file = "protobuf-5.29.3-cp39-cp39-win_amd64.whl", hash = "sha256:6ce8cc3389a20693bfde6c6562e03474c40851b44975c9b2bf6df7d8c4f864da"}, + {file = "protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f"}, + {file = "protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620"}, +] + +[[package]] +name = "psutil" +version = "7.1.3" +description = "Cross-platform lib for process and system monitoring." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc"}, + {file = "psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0"}, + {file = "psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7"}, + {file = "psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251"}, + {file = "psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa"}, + {file = "psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee"}, + {file = "psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353"}, + {file = "psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b"}, + {file = "psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9"}, + {file = "psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f"}, + {file = "psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7"}, + {file = "psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264"}, + {file = "psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab"}, + {file = "psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880"}, + {file = "psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3"}, + {file = "psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b"}, + {file = "psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd"}, + {file = "psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1"}, + {file = "psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""] +test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "setuptools", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, + {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pyopenssl" +version = "25.0.0" +description = "Python wrapper module around the OpenSSL library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyOpenSSL-25.0.0-py3-none-any.whl", hash = "sha256:424c247065e46e76a37411b9ab1782541c23bb658bf003772c3405fbaa128e90"}, + {file = "pyopenssl-25.0.0.tar.gz", hash = "sha256:cd2cef799efa3936bb08e8ccb9433a575722b9dd986023f1cabc4ae64e9dac16"}, +] + +[package.dependencies] +cryptography = ">=41.0.5,<45" +typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] +test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] + +[[package]] +name = "pyparsing" +version = "3.2.1" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyparsing-3.2.1-py3-none-any.whl", hash = "sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1"}, + {file = "pyparsing-3.2.1.tar.gz", hash = "sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a"}, +] + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "python-avatars" +version = "1.4.1" +description = "SVG avatar library for Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python_avatars-1.4.1-py3-none-any.whl", hash = "sha256:ef97b1f8ac23583367705f876fbbac1cc118435982532d882ccc788e95663722"}, + {file = "python_avatars-1.4.1.tar.gz", hash = "sha256:133dc0e1dfd778f0287aa6b6697da2677aeb3ce985ebf908205068e963165b0e"}, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2025.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"}, + {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "redis" +version = "6.4.0" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f"}, + {file = "redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +hiredis = ["hiredis (>=3.2.0)"] +jwt = ["pyjwt (>=2.9.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] + +[[package]] +name = "regex" +version = "2024.11.6" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"}, + {file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"}, + {file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"}, + {file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"}, + {file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"}, + {file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"}, + {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"}, + {file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"}, + {file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"}, + {file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"}, + {file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"}, + {file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"}, + {file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"}, + {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, +] + +[[package]] +name = "requests" +version = "2.32.4" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +groups = ["main"] +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +groups = ["main"] +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "sentry-sdk" +version = "2.25.1" +description = "Python client for Sentry (https://sentry.io)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "sentry_sdk-2.25.1-py2.py3-none-any.whl", hash = "sha256:60b016d0772789454dc55a284a6a44212044d4a16d9f8448725effee97aaf7f6"}, + {file = "sentry_sdk-2.25.1.tar.gz", hash = "sha256:f9041b7054a7cf12d41eadabe6458ce7c6d6eea7a97cfe1b760b6692e9562cf0"}, +] + +[package.dependencies] +certifi = "*" +urllib3 = ">=1.26.11" + +[package.extras] +aiohttp = ["aiohttp (>=3.5)"] +anthropic = ["anthropic (>=0.16)"] +arq = ["arq (>=0.23)"] +asyncpg = ["asyncpg (>=0.23)"] +beam = ["apache-beam (>=2.12)"] +bottle = ["bottle (>=0.12.13)"] +celery = ["celery (>=3)"] +celery-redbeat = ["celery-redbeat (>=2)"] +chalice = ["chalice (>=1.16.0)"] +clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] +django = ["django (>=1.8)"] +falcon = ["falcon (>=1.4)"] +fastapi = ["fastapi (>=0.79.0)"] +flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] +grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] +http2 = ["httpcore[http2] (==1.*)"] +httpx = ["httpx (>=0.16.0)"] +huey = ["huey (>=2)"] +huggingface-hub = ["huggingface_hub (>=0.22)"] +langchain = ["langchain (>=0.0.210)"] +launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] +litestar = ["litestar (>=2.0.0)"] +loguru = ["loguru (>=0.5)"] +openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] +openfeature = ["openfeature-sdk (>=0.7.1)"] +opentelemetry = ["opentelemetry-distro (>=0.35b0)"] +opentelemetry-experimental = ["opentelemetry-distro"] +pure-eval = ["asttokens", "executing", "pure_eval"] +pymongo = ["pymongo (>=3.1)"] +pyspark = ["pyspark (>=2.4.4)"] +quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] +rq = ["rq (>=0.6)"] +sanic = ["sanic (>=0.8)"] +sqlalchemy = ["sqlalchemy (>=1.2)"] +starlette = ["starlette (>=0.19.1)"] +starlite = ["starlite (>=1.48)"] +statsig = ["statsig (>=0.55.3)"] +tornado = ["tornado (>=6)"] +unleash = ["UnleashClient (>=6.0.1)"] + +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sqlparse" +version = "0.5.3" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, + {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "stripe" +version = "11.5.0" +description = "Python bindings for the Stripe API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "stripe-11.5.0-py2.py3-none-any.whl", hash = "sha256:3b2cd47ed3002328249bff5cacaee38d5e756c3899ab425d3bd07acdaf32534a"}, + {file = "stripe-11.5.0.tar.gz", hash = "sha256:bc3e0358ffc23d5ecfa8aafec1fa4f048ee8107c3237bcb00003e68c8c96fa02"}, +] + +[package.dependencies] +requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "tweepy" +version = "4.15.0" +description = "Twitter library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tweepy-4.15.0-py3-none-any.whl", hash = "sha256:64adcea317158937059e4e2897b3ceb750b0c2dd5df58938c2da8f7eb3b88e6a"}, + {file = "tweepy-4.15.0.tar.gz", hash = "sha256:1345cbcdf0a75e2d89f424c559fd49fda4d8cd7be25cd5131e3b57bad8a21d76"}, +] + +[package.dependencies] +oauthlib = ">=3.2.0,<4" +requests = ">=2.27.0,<3" +requests-oauthlib = ">=1.2.0,<3" + +[package.extras] +async = ["aiohttp (>=3.7.3,<4)", "async-lru (>=1.0.3,<3)"] +dev = ["coverage (>=4.4.2)", "coveralls (>=2.1.0)", "tox (>=3.21.0)"] +docs = ["myst-parser (==0.15.2)", "readthedocs-sphinx-search (==0.1.1)", "sphinx (==4.2.0)", "sphinx-hoverxref (==0.7b1)", "sphinx-tabs (==3.2.0)", "sphinx_rtd_theme (==1.0.0)"] +socks = ["requests[socks] (>=2.27.0,<3)"] +test = ["urllib3 (<2)", "vcrpy (>=1.10.3)"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] +markers = {dev = "python_version == \"3.10\""} + +[[package]] +name = "tzdata" +version = "2025.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639"}, + {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"}, +] + +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + +[[package]] +name = "urllib3" +version = "2.6.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, + {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "uvicorn" +version = "0.34.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"}, + {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "virtualenv" +version = "20.29.2" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a"}, + {file = "virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + +[[package]] +name = "whitenoise" +version = "6.9.0" +description = "Radically simplified static file serving for WSGI applications" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "whitenoise-6.9.0-py3-none-any.whl", hash = "sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df"}, + {file = "whitenoise-6.9.0.tar.gz", hash = "sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609"}, +] + +[package.extras] +brotli = ["brotli"] + +[metadata] +lock-version = "2.1" +python-versions = "^3.10" +content-hash = "e27798cb7b36d69c67693ec009aa7f3f828552ee23a4a99494cede1ab7efb5a6" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f7736e7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[tool.poetry] +name = "education-website" +version = "0.1.0" +description = "Alpha One Labs Educational Platform" +authors = ["Alpha One Labs "] +packages = [ + { include = "web" } +] + +[tool.poetry.dependencies] +python = "^3.10" +django = "^5.1" +django-environ = "^0.11.2" +django-simple-captcha = "^0.5.20" +requests = "^2.32.4" +djlint = "^1.36.4" +stripe = "^11.4.1" +google-auth-oauthlib = "^1.2.0" +google-auth-httplib2 = "^0.2.0" +google-api-python-client = "^2.118.0" +icalendar = "^5.0.11" +whitenoise = "^6.8.2" +django-allauth = "^65.3.1" +django-storages = "^1.14.4" +django-markdownx = "^4.0.7" +django-browser-reload = "^1.18.0" +python-avatars = "^1.4.1" +cryptography = "^44.0.2" +tweepy = "^4.15.0" +pillow = "^12.1.1" +uvicorn = "^0.34.0" +sentry-sdk = "^2.25.1" +pyopenssl = "^25.0.0" +oauth2client = "4.1.3" +bleach = "^6.2.0" +channels = "^4.3.1" +channels-redis = "^4.3.0" +redis = "^6.4.0" +mysqlclient = "^2.2.4" +psutil = "^7.1.3" + +[tool.poetry.group.dev.dependencies] +djlint = "^1.34.1" +pre-commit = "^3.6.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.djlint] +profile = "django" +indent = 2 +blank_line_after_tag = "load,extends" +close_void_tags = true +format_css = true +format_js = true + +[tool.black] +line-length = 120 +target-version = ['py312'] +include = '\.pyi?$' + +[tool.isort] +profile = "ruff" +multi_line_output = 3 +line_length = 120 diff --git a/remove_navbar.py b/remove_navbar.py new file mode 100644 index 0000000..d5fbf6a --- /dev/null +++ b/remove_navbar.py @@ -0,0 +1,19 @@ +filepath = "web/templates/base.html" +with open(filepath, "r") as f: + lines = f.readlines() + +new_lines = [] +skip = False +for line in lines: + if '
0 to enable performance + # Do not send Invalid Host (DisallowedHost) errors to Sentry + ignore_errors=(DisallowedHost,), + ) +else: + # Helpful notice for ops without breaking startup + print("Sentry DSN not configured; error events will not be sent.") + +SECRET_KEY = env.str("SECRET_KEY", default="django-insecure-5kyff0s@l_##j3jawec5@b%!^^e(j7v)ouj4b7q6kru#o#a)o3") +# Debug settings +ENVIRONMENT = env.str("ENVIRONMENT", default="development") + +# Default DEBUG to False for security +DEBUG = False + +# Only enable DEBUG in local environment and only if DJANGO_DEBUG is True +if ENVIRONMENT == "development": + DEBUG = True + +# Detect test environment and set DEBUG=True to use local media path +if "test" in sys.argv: + TESTING = True + DEBUG = True +else: + TESTING = False + +PA_USER = "alphaonelabs99282llkb" +PA_HOST = PA_USER + ".pythonanywhere.com" +PA_WSGI = "/var/www/" + PA_USER + "_pythonanywhere_com_wsgi.py" +PA_SOURCE_DIR = "/home/" + PA_USER + "/web" + +# Social Media Settings +TWITTER_USERNAME = "alphaonelabs" +TWITTER_API_KEY = os.getenv("TWITTER_API_KEY") +TWITTER_API_SECRET_KEY = os.getenv("TWITTER_API_SECRET_KEY") +TWITTER_ACCESS_TOKEN = os.getenv("TWITTER_ACCESS_TOKEN") +TWITTER_ACCESS_TOKEN_SECRET = os.getenv("TWITTER_ACCESS_TOKEN_SECRET") + +# Production settings +if not DEBUG: + # SECURE_SSL_REDIRECT = True + # adding this to prevent redirect loop + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SECURE_HSTS_SECONDS = 31536000 # 1 year + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_PRELOAD = True + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + SECURE_REDIRECT_EXEMPT = [] + SECURE_SSL_HOST = "alphaonelabs.com" + SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" + +# Allow hosts list can be overridden via .env (comma-separated) while providing a strong default. +ALLOWED_HOSTS = env.list( + "ALLOWED_HOSTS", + default=[ + "alphaonelabs99282llkb.pythonanywhere.com", + "0.0.0.0", + "127.0.0.1", + "localhost", + "alphaonelabs.com", + ".alphaonelabs.com", + ], +) + +# CSRF trusted origins can also be overridden through .env (comma-separated). +CSRF_TRUSTED_ORIGINS = env.list( + "CSRF_TRUSTED_ORIGINS", + default=[ + "https://alphaonelabs.com", + "https://www.alphaonelabs.com", + "http://127.0.0.1:8000", + "http://localhost:8000", + ], +) + +# Timezone settings +TIME_ZONE = "America/New_York" +USE_TZ = True + +# Error handling +handler404 = "django.views.defaults.page_not_found" +# Custom handler for 429 (too many requests) +# handler429 = "web.views.custom_429" + +# Admin notification settings +ADMINS = [("Admin", os.getenv("EMAIL_FROM"))] +SERVER_EMAIL = os.getenv("EMAIL_FROM") # Email address error messages come from + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.sites", + "django.contrib.humanize", + "channels", + "allauth", + "allauth.account", + "captcha", + "markdownx", + "web", + "web.virtual_lab.apps.VirtualLabConfig", +] + +if DEBUG and not TESTING: + INSTALLED_APPS.append("django_browser_reload") + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + # Compress responses to reduce payload size + "django.middleware.gzip.GZipMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "allauth.account.middleware.AccountMiddleware", + # "web.middleware.GlobalExceptionMiddleware", +] + +if DEBUG and not TESTING: + MIDDLEWARE.insert(-2, "django_browser_reload.middleware.BrowserReloadMiddleware") + +ROOT_URLCONF = "web.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "web/templates"], + # Use cached loaders in production to avoid repeated template parsing + "APP_DIRS": DEBUG, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + **( + {} + if DEBUG + else { + "loaders": [ + ( + "django.template.loaders.cached.Loader", + [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + ) + ] + } + ), + }, + }, +] + +CAPTCHA_FONT_SIZE = 28 +CAPTCHA_IMAGE_SIZE = (150, 40) +CAPTCHA_LETTER_ROTATION = (-20, 20) +CAPTCHA_BACKGROUND_COLOR = "#f0f8ff" +CAPTCHA_FOREGROUND_COLOR = "#2f4f4f" +CAPTCHA_NOISE_FUNCTIONS = ("captcha.helpers.noise_arcs", "captcha.helpers.noise_dots") +CAPTCHA_FILTER_FUNCTIONS = ("captcha.helpers.post_smooth",) +CAPTCHA_2X_IMAGE = True +CAPTCHA_TEST_MODE = False + +WSGI_APPLICATION = "web.wsgi.application" + +# Add ASGI application configuration + +# Channels / Redis channel layer configuration (assumes a local Redis unless overridden) +REDIS_URL = env.str("REDIS_URL", default="redis://127.0.0.1:6379/0") +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": {"hosts": [REDIS_URL]}, + } +} + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + # Persist DB connections to avoid reconnect overhead in production + "CONN_MAX_AGE": 300 if not DEBUG else 0, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +SITE_ID = 1 +SITE_NAME = "AlphaOne Labs" +SITE_DOMAIN = "alphaonelabs.com" + +# Allauth settings +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_USERNAME_REQUIRED = False # Since we're using email authentication +ACCOUNT_EMAIL_VERIFICATION = "mandatory" # Require email verification +ACCOUNT_LOGIN_METHODS = {"email"} +ACCOUNT_UNIQUE_EMAIL = True +ACCOUNT_PREVENT_ENUMERATION = True # Prevent user enumeration +ACCOUNT_USERNAME_MIN_LENGTH = 3 +ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True +ACCOUNT_SESSION_REMEMBER = None # Let user decide via checkbox +ACCOUNT_REMEMBER_ME_FIELD = "remember" # Match test field name +ACCOUNT_LOGIN_ON_PASSWORD_RESET = True +ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False +ACCOUNT_LOGOUT_ON_GET = True +ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE = False +ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = False +ACCOUNT_OLD_PASSWORD_FIELD_ENABLED = True +ACCOUNT_EMAIL_AUTHENTICATION = True # Enable email authentication +ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = "index" +ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = "account_login" + +# Authentication backends +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", +] + +# Authentication URLs +LOGIN_URL = "account_login" +LOGIN_REDIRECT_URL = "index" +LOGOUT_REDIRECT_URL = "index" + +ACCOUNT_RATE_LIMITS = { + "login_attempt": "5/5m", # 5 attempts per 5 minutes + "login_failed": "3/5m", # 3 failed attempts per 5 minutes + "signup": "5/h", # 5 signups per hour + "send_email": "5/5m", # 5 emails per 5 minutes + "change_email": "3/h", # 3 email changes per hour +} + +# Override allauth forms + +LANGUAGE_CODE = "en" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +STATIC_URL = "/static/" + + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +""" +Media files configuration + +Previously MEDIA_ROOT for production was hard-coded to the legacy PythonAnywhere +path (/home/alphaonelabs99282llkb/web/media). That prevented the live server +from locating media we now place under the project directory on the new VPS. + +We switch to an environment-variable override with a sane default pointing to +/media for both dev and prod (unless a cloud storage backend is +configured later). This keeps URLs stable (/media/...) while aligning file +paths with the Nginx alias in ansible/nginx-http.conf.j2: + location /media/ { alias /home/django/education-website/media/; } + +If GS_BUCKET_NAME is set above, DEFAULT_FILE_STORAGE will override this with +Google Cloud Storage; in that case MEDIA_ROOT is less relevant. +""" +MEDIA_ROOT = env.str("MEDIA_ROOT", default=str(BASE_DIR / "media")) +MEDIA_URL = "/media/" + +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") +STATICFILES_DIRS = [BASE_DIR / "static"] +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +# Caching configuration: fast in-memory cache by default; can be +# overridden via environment (e.g., django-redis) without code changes. +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "alphaonelabs-local", + "TIMEOUT": 300, + "OPTIONS": {"MAX_ENTRIES": 10000}, + } +} + +# Cache middleware settings (usable when wrapping views or enabling site-wide cache) +CACHE_MIDDLEWARE_ALIAS = "default" +CACHE_MIDDLEWARE_SECONDS = int(os.getenv("CACHE_MIDDLEWARE_SECONDS", "300")) +CACHE_MIDDLEWARE_KEY_PREFIX = os.getenv("CACHE_MIDDLEWARE_KEY_PREFIX", "aol") + +# Use cached sessions in normal runtime to reduce DB hits; keep default during tests +if not TESTING: + SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" + +# Drop noisy Invalid Host messages from logs entirely +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "null": {"class": "logging.NullHandler"}, + # Ensure any accidental use of 'mail_admins' will be a no-op + "mail_admins": {"class": "logging.NullHandler"}, + }, + "loggers": { + # Django emits DisallowedHost on invalid/missing Host header; silence it + "django.security.DisallowedHost": { + "handlers": ["null"], + "level": "ERROR", + "propagate": False, + }, + # Do not email admins on request/server errors + "django.request": { + "handlers": ["null"], + "level": "ERROR", + "propagate": False, + }, + "django.server": { + "handlers": ["null"], + "level": "ERROR", + "propagate": False, + }, + }, +} + +# Email settings +if DEBUG: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + print("Using console email backend for development") + DEFAULT_FROM_EMAIL = "noreply@example.com" # Default for development + MAILGUN_SENDING_KEY = None # Not needed in development +else: + # Production email settings + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + MAILGUN_SENDING_KEY = env.str("MAILGUN_SENDING_KEY", default="") + # Optional: set MAILGUN_DOMAIN explicitly; otherwise inferred from DEFAULT_FROM_EMAIL + MAILGUN_DOMAIN = env.str("MAILGUN_DOMAIN", default="") or None + DEFAULT_FROM_EMAIL = env.str("EMAIL_FROM", default="noreply@alphaonelabs.com") + EMAIL_FROM = os.getenv("EMAIL_FROM") + +# Stripe settings +STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", default="") +STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", default="") +STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="") + +# Social Media and Content API Settings +MAILCHIMP_API_KEY = env.str("MAILCHIMP_API_KEY", default="") +MAILCHIMP_LIST_ID = env.str("MAILCHIMP_LIST_ID", default="") + +INSTAGRAM_ACCESS_TOKEN = env.str("INSTAGRAM_ACCESS_TOKEN", default="") +FACEBOOK_ACCESS_TOKEN = env.str("FACEBOOK_ACCESS_TOKEN", default="") + +GITHUB_ACCESS_TOKEN = env.str("GITHUB_ACCESS_TOKEN", default="") +GITHUB_REPO = env.str("GITHUB_REPO", default="AlphaOneLabs/education-website") + +YOUTUBE_API_KEY = env.str("YOUTUBE_API_KEY", default="") +YOUTUBE_CHANNEL_ID = env.str("YOUTUBE_CHANNEL_ID", default="") + +TWITTER_USERNAME = env.str("TWITTER_USERNAME", default="alphaonelabs") + +# Slack Integration +SLACK_WEBHOOK_URL = env.str("SLACK_WEBHOOK_URL", default="") + +# Slack webhook for email notifications +EMAIL_SLACK_WEBHOOK = env.str("EMAIL_SLACK_WEBHOOK", default=SLACK_WEBHOOK_URL) + +LANGUAGES = [ + ("en", "English"), + ("es", "Spanish"), + ("fr", "French"), + ("de", "German"), + ("zh-hans", "Simplified Chinese"), +] + +LOCALE_PATHS = [ + BASE_DIR / "locale", +] + +USE_L10N = True + +if os.environ.get("DATABASE_URL"): + DATABASES = {"default": env.db()} + + # Only add MySQL-specific options if using MySQL + if DATABASES["default"]["ENGINE"] == "django.db.backends.mysql": + DATABASES["default"]["OPTIONS"] = { + "charset": "utf8mb4", + "sql_mode": ( + "STRICT_TRANS_TABLES," + "NO_ZERO_IN_DATE," + "NO_ZERO_DATE," + "ERROR_FOR_DIVISION_BY_ZERO," + "NO_ENGINE_SUBSTITUTION" + ), + "init_command": "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_unicode_ci'", + } + + # Google Cloud Storage settings for media files in production + if os.environ.get("GS_BUCKET_NAME"): + DEFAULT_FILE_STORAGE = "storages.backends.gcloud.GoogleCloudStorage" + GS_BUCKET_NAME = os.environ.get("GS_BUCKET_NAME") + GS_PROJECT_ID = os.environ.get("GS_PROJECT_ID") + + # Get service account file path from .env + service_account_filename = env.str("SERVICE_ACCOUNT_FILE") + SERVICE_ACCOUNT_FILE = os.path.join(BASE_DIR, service_account_filename) + if os.path.exists(SERVICE_ACCOUNT_FILE): + from google.oauth2 import service_account + + GS_CREDENTIALS = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE) + else: + print(f"Warning: Service account file not found at {SERVICE_ACCOUNT_FILE}") + GS_CREDENTIALS = None + + GS_DEFAULT_ACL = "publicRead" + GS_QUERYSTRING_AUTH = False + GS_LOCATION = "media" # Store files in a media directory in the bucket + + +# Admin URL Configuration +ADMIN_URL = env.str("ADMIN_URL", default="a-dmin-url123") + +# Markdownx configuration +MARKDOWNX_MARKDOWN_EXTENSIONS = [ + "markdown.extensions.extra", + "markdown.extensions.codehilite", + "markdown.extensions.tables", + "markdown.extensions.toc", +] + +MARKDOWNX_URLS_PATH = "/markdownx/markdownify/" +MARKDOWNX_UPLOAD_URLS_PATH = "/markdownx/upload/" +MARKDOWNX_MEDIA_PATH = "markdownx/" # Path within MEDIA_ROOT + +USE_X_FORWARDED_HOST = True + +# GitHub API Token for fetching contributor data +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") + + +# Allow per-environment override of secure cookie behavior (useful for staging without HTTPS). +CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=not DEBUG) +SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=not DEBUG) +GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "") diff --git a/web/static/img/image.png b/web/static/img/image.png new file mode 100644 index 0000000000000000000000000000000000000000..bdb6de16076893714d5fc8f3a81b934bd41ab8ce GIT binary patch literal 5111 zcmVPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91WS|281ONa40RR91X8-^I0B9SekpKV_VM#V5;Hk@-Jfg zn-)}6RS8rmTT0E!7^S{#n3r9;bSeFvcc%MHcAc!*)zw*9Q&ZD6%p;>NSVNUcRQ#wE zo0u4(o}OM*Qc_Z!ay#X_+qZ9qr=_JmQKfvcnHQA<^sIj3Q1q1{j`*GvY{reA! zr~Q%yNDcAZu@i$gHa0fKTYR?S{DOzLxw!>LN5|A`_VNAn zrAJ=hvMB*kKnN)0(AU?;n>PF4v~(Ws?Civ0VORV2?mw_+;lhQ6Z06;4+8_ZsKnN%r z8XDNr(h_gnv<2TyO~cQh*JDmhT-E2Ff3eTq-QE7R@K}-n6CebXUxBg0ettVJKO+-Y zR6N1S*KZbn^2w(k&6zXDk)>??c9R_r5Fs$+Y-weMcL3jH=iI|jpVs2|goHwW|6PZ? zyu8eRyAo7z$)y2A2o53tt-jlE!NWp|{3D~H3HcvUaMpgC$;kqU5Eugk1MIWa7xVZT zxV@ueFf=s0ckjLfyI+&P9RDSG03pB-^0%_Grn<{%Y3aDGt`0|YVk<&I{(H~-`SZug zku{|~N~=0K`0?MooSi558yOmugI=joS-A-g-V>6VmWJ--WXt?YP`RMo+y^K&HWodt zeTEh-T4ee=clTvernxP1uy@q$>l0L~ol54$t(1M$HBa%ih{)&bH*7kwY}qm^nI$B} zgYwo8coo$f6Frj}vvXGfh7mtS{;_fK54Ugk|7hjPm8Mc;ZDgYoK)QQ+FmW9n9r(w~ zSMb_(>%Vk%b~a%%XKVl>1jpFe1aI@(jvp2l;il$h91#&!y=lwV!)%sEVQGs3LZJdb zot<4cG%UO)D0uJxI668`P}s{v5@Hh&cn!rdMn*>1ciVPq9Mjg;KKN5;SodG|9}G}w z@}~oY05do^i0kX?aa;oT>Fzy${fCp2(_|5O%6-W;AOsX4FxECU0E{2z@%Xs5_8E?h z=9GQ**%v{xX1!-8cR>`L023g>+A;_i_4N%zeNpM_=XVU$7ZVkc2W0_5Kp_X|m04Tc z;9UWM)Bv-ryaIE%$$1A39Z{`b*>Hdefg$JmhDIEI_1bf9@4x)h)6-L~QL?fD5duTb z1ek!pAe@_*kD+%4f42AnUtiyS8#it=hVYSf(f}d^2L@yKhf6;a^8Z3s@*hb+K;l%b zF*Y&5+jscm!lGi_)YOcxUX7^Qlm&prxjD7Qls? zyNHVBRQUM#{$s&{1!8NAssZvVFdEp##uf(!1ycjeva)iVz)gCvfB!+{^vV+5x7j0wM&4oJn_ickmu+YMGFKR8&m<=FL8PK<&@o3?>_Z z2*GIwj``R3KM?W{nK*IckRFUW03rm3aE$-XUAUyt;n%9RY}vXW!WHk>1cU%X znzGimHhlS~(Ds0!;4gq<-Xx_^CqN*(6#1K)nNhm3%&crOA2V?I@=vXx_V<&O{Asnl zVC~`V`*@K&i0v98Y`%nN zQe#N3jErRtANd3WSX3_ua;jEm(p|QJURm&-y*M{F4?iv|!*TK4d(d6pHFxe@o8cvo z(1_HKmor9c49xf;9UUDsbH+^M;5Z&VdGZ7`H8mmVLcbhcrSCgCI#33mkD{WYQAcMd z^7LG4>#^A5Lnp^KJTXZgUlS8k;Fv&){2O894^zvPp!VOZc7PCY$N;msr3HU`{sNve=Uoh(_M)Jog;w=z z(v+E7Sb(lN4=G}Masx2_2rrX=w zQ%X!?zEo9JjlcZ+(XWTCEvg2H5F7%`Gca5@dFnKNXZm!tCVyKyJN%@w8lO6Q{;DV- znxZ$VehpP0*;renpuhlhk(W8u!9PI58Hz|oRoCFvd z8X~`KThW@;D^d8>2z24Xd368YJv1=TFZMY}15chhgVuX{NgADs2ZrHN#TSh{qJUfm zK#EFAMe{S{x}>ZO6+J9OlO~JT-jk-x%*+h!^xuw_FaHp1vf|N20?hq;sJ~x6V2q3m zk%fiDkbJRuGh%hTqP!gCgRsT9fq_938yAa6n=6SUz>KrAqkxHvk3+|f{}WA{=0*)T zC3#*I;*1y|ckbLl&>tUG28qyQfo<5Z@kApKU~Fw{Q1I>`^z+S|=zqUDhFn}GA?S9B zMpMb_5zAp%SeQi3Nmh0?0&`ZGg=Y=qPk_nH$^`QN7P(AvRcZ1c5kSa4K8?qhNXWf{ zJ`^1lB~e^)kqG&3_uGb2@7_fxPn|+DXU$T{?(&F`dJ>n5z*JS7{Dy?319-5$kWxSn z36S!JG-VbR<|ts-PPAhAaufpw*%vQdpp3Jb$Ui!o1M@T`ue@p2%$aEQsui#Jv*N+# zNWHhW7yH5j8*N=Zi4VJR<8WSXu5iGvu=9UZGSBdTg?x_n^z@|ClabZJOb$SiC@nG} zH{v7pmX;QD^XAV|{gE0Pn@~#19jTs?Br>TncD6R?{rBHTIxu0T3Qp-9a_4pms;zr2 zb=l*&38=qMx@iQdU!npJC>0QBB2e;;>x{+L(bk3@5X@H5xcvMAXzO!nLPnRDQUR%{ zu0|OdnT!%#@#hbbg*m->a6@AwN>Ar8K08_hlnO{;VIg{4UPdcBQZs@A1JNXxw`s#k z?TUj@`)GGc1tc;e67}`b>z3?ntkLflEC5~9a^_|WR4q>`Dn>OXN(1C+O$|!r(Kk7y zc7WMGC1_Zn3C)%ntNB{)_OXo}S3ifZlewySoQPMMl1eJK8Rk0>~{;frEtwt?cw* zgzV}0{*W+O<%_0HoifBv^yV&XF3>kh2Z>4nogBpu(Q4 z0zw2A7az}PFzx)t8|dw~-l7!`?1P0CFIh|*Mm+DPrlLwP$R2Hu)d{M$)>afBpTH7uq9r|M&76gt-*l!GS5I#*`sLquX~WdflNCU+vvWY#)J!W6VnGNSx6p<$dBSQV zG-Jk4eV1rlvIFVBz<{Wi+FmOh5HdQCjEY2LoIvL$!a#1;?AdfdV*SW?V+;%=>0D)X z4dOG*M$*MsWk327%8}!e;$oD`Fc>6LwQJU_mMQhApx1I3XV3U-0+^=~LkM+u3I~Kt zM71*XuFOqM(SrH&Ws(_D(|dR@tK?&2;}EfmRClLvKq4X{7~Atnu9MIVm@}8fk=o_C zbSa}nN-3l?`r zCLRC!%K0<=w*@U6K|iX7xs~?z zHi?&E%UgE%UJ8Qk%^z~(j%43LU*};T_TtBRWyfFr=z3m1R81U9Xoa)V`EclyFoUz0CY7(Za|2X zQ@C8gjq6E#g!Q7ETbs&(TO4$Bb!^myV+bIRODm3l|NVsq*z$v)mzNv;sI;^k?8Y2m zL8y(-mMzHC)Ere*Q~<}cE37GF4G`g9KRcV(5zC3rgtm)=v1S>JE?r=AOLH>sp~_AO zNJ`4j)YKzzV@G~oUMB1+#DU#X%LIZx7cZ~1=ESH^f2;w5ZC_!#5BY(i1B6vMZ9Z_o zAna#e()iD#PZOgkEw5t zfR0UOj-lfaraUOzA4dKMc>GM2B>&+65fz4q$C3l5&YY%G0r)~;Pktq7~BsiyY&0^$@tGUQAL$dCvlbrTpEXc%@iA|N%5*A3t(o&2TY5OFfH zva&Mt^70P&=ETWv*yv!8e8Kvo0|&9Cr4{uh1$cG{&(Lv*xb#OP?Bwawhxr*<)yf?C zD+&-%fi3j-MQ2K&hzcDJ?BY&(6whEU@|P1HkD^yo-Y4v0U~@C zs9V}{AKd3l^BqG`F-B+`jp<`Y*f(wf~Wf%KS(PMpNu>ki z-<_D4kO~b~YHVVqY7L06Aeot&ZBbE?>HYm=Q1Os(oNx9U{jv{}tiK-GHVlz4# z0>3$V`qRwp9I@oDN~At2nk;dDoJ4NIFD`Gtbt%7~utk5YzJrOeiKUj-&|<4n4H=p< zaV1mZFj#=vl@K3y7Yvo-+B>^zVHay4e;rHV0P{5 + + + + + + + + + + + {% block title %} Alpha One Labs - Open Source Education Platform {% endblock title %} + + + + + + + + + + + + {% block extra_head %} {% endblock extra_head %} + + + + {% if messages %} +
+ {% for message in messages %} +
+
+ {% if message.tags == 'error' %} + + {% elif message.tags == 'success' %} + + {% elif message.tags == 'warning' %} + + {% else %} + + {% endif %} +
+

{{ message }}

+ +
+ {% endfor %} +
+ {% endif %} {% block extra_body %} {% block content %} {% endblock content %} {% endblock extra_body %} {% block base_footer %} + + + {% endblock base_footer %} + + {% block extra_js %} {% endblock extra_js %} + + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..e7ddd89 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,780 @@ +{% verbatim %} + + + + + + Alpha One Labs - Instrument + + + + + + + + + + + + + +
+
+
+
+
+
+ Loading System +
+
+
+
+
+
+ +
+ + + + + + +{% endverbatim %} + diff --git a/web/urls.py b/web/urls.py new file mode 100644 index 0000000..bccb722 --- /dev/null +++ b/web/urls.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from django.urls import include, path +from django.conf import settings +from django.conf.urls.static import static +from django.views.generic import TemplateView + +urlpatterns = [ + path("", TemplateView.as_view(template_name="index.html"), name="index"), + path("admin/", admin.site.urls), + path("virtual_lab/", include("web.virtual_lab.urls")), +] + +if settings.DEBUG: + urlpatterns.append(path("__reload__/", include("django_browser_reload.urls"))) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/web/views.py b/web/views.py new file mode 100644 index 0000000..8dd972d --- /dev/null +++ b/web/views.py @@ -0,0 +1,8846 @@ +import calendar +import html +import ipaddress +import json +import logging +import os +import random +import re +import shutil +import socket +import string +import subprocess +import time +from collections import Counter, defaultdict +from datetime import datetime, timedelta +from decimal import Decimal +from urllib.parse import urlparse + +import requests +import stripe +import tweepy +from allauth.account.models import EmailAddress +from allauth.account.utils import send_email_confirmation +from django.conf import settings +from django.contrib import messages +from django.contrib.admin.utils import NestedObjects +from django.contrib.auth import get_user_model, login, logout +from django.contrib.auth.decorators import login_required, user_passes_test +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.contrib.auth.models import User +from django.core.cache import cache +from django.core.exceptions import ObjectDoesNotExist +from django.core.mail import send_mail +from django.core.management import call_command +from django.core.paginator import Paginator +from django.db import IntegrityError, models, router, transaction +from django.db.models import Avg, Count, Q, Sum +from django.db.models.functions import Coalesce +from django.http import ( + FileResponse, + Http404, + HttpRequest, + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + JsonResponse, +) +from django.shortcuts import get_object_or_404, redirect, render +from django.template.loader import render_to_string +from django.urls import NoReverseMatch, reverse, reverse_lazy +from django.utils import timezone +from django.utils.crypto import get_random_string +from django.utils.html import strip_tags +from django.utils.text import slugify +from django.utils.translation import gettext as _ +from django.views import generic +from django.views.decorators.clickjacking import xframe_options_exempt +from django.views.decorators.csrf import csrf_exempt, csrf_protect +from django.views.decorators.http import require_GET, require_POST +from django.views.generic import ( + CreateView, + DeleteView, + DetailView, + ListView, + UpdateView, +) + +from .calendar_sync import generate_google_calendar_link, generate_ical_feed, generate_outlook_calendar_link +from .decorators import teacher_required +from .forms import ( + AccountDeleteForm, + AwardAchievementForm, + BlogPostForm, + ChallengeSubmissionForm, + CourseForm, + CourseMaterialForm, + EducationalVideoForm, + FeedbackForm, + ForumCategoryForm, + ForumTopicForm, + GoodsForm, + GradeableLinkForm, + InviteStudentForm, + LearnForm, + LinkGradeForm, + MemeForm, + MessageTeacherForm, + NotificationPreferencesForm, + ProfileUpdateForm, + ProgressTrackerForm, + ReviewForm, + SessionForm, + StorefrontForm, + StudentEnrollmentForm, + StudyGroupForm, + SuccessStoryForm, + SurveyForm, + TeacherSignupForm, + TeachForm, + TeamGoalCompletionForm, + TeamGoalForm, + TeamInviteForm, + UserRegistrationForm, + VideoRequestForm, + VirtualClassroomCustomizationForm, + VirtualClassroomForm, +) +from .marketing import ( + generate_social_share_content, + get_course_analytics, + get_promotion_recommendations, + send_course_promotion_email, +) +from .models import ( + Achievement, + Badge, + BlogComment, + BlogPost, + CartItem, + Certificate, + Challenge, + ChallengeSubmission, + Choice, + Course, + CourseMaterial, + CourseProgress, + Discount, + Donation, + EducationalVideo, + Enrollment, + EventCalendar, + FeatureVote, + ForumCategory, + ForumReply, + ForumTopic, + ForumVote, + Goods, + GradeableLink, + LearningStreak, + LinkGrade, + MembershipPlan, + MembershipSubscriptionEvent, + Meme, + NoteHistory, + Notification, + NotificationPreference, + Order, + OrderItem, + Payment, + PeerConnection, + PeerMessage, + ProductImage, + Profile, + ProgressTracker, + Question, + Response, + Review, + ScheduledPost, + SearchLog, + Session, + SessionAttendance, + SessionEnrollment, + Storefront, + StudyGroup, + StudyGroupInvite, + Subject, + SuccessStory, + Survey, + TeamGoal, + TeamGoalMember, + TeamInvite, + TimeSlot, + UserBadge, + UserMembership, + VideoRequest, + VirtualClassroom, + VirtualClassroomCustomization, + VirtualClassroomParticipant, + WaitingRoom, + WebRequest, + default_valid_until, +) +from .notifications import ( + notify_session_reminder, + notify_teacher_new_enrollment, + notify_teacher_waiting_room_join, + notify_team_goal_completion, + notify_team_invite, + notify_team_invite_response, + send_enrollment_confirmation, +) +from .referrals import send_referral_reward_email +from .social import get_social_stats +from .utils import ( + cancel_subscription, + create_leaderboard_context, + create_subscription, + geocode_address, + get_cached_challenge_entries, + get_cached_leaderboard_data, + get_leaderboard, + get_or_create_cart, + get_user_points, + reactivate_subscription, + setup_stripe, +) + +logger = logging.getLogger(__name__) + +GOOGLE_CREDENTIALS_PATH = os.path.join(settings.BASE_DIR, "google_credentials.json") + +# Initialize Stripe +stripe.api_key = settings.STRIPE_SECRET_KEY + + +def sitemap(request): + return render(request, "sitemap.html") + + +def handle_referral(request, code): + """Handle referral link with the format /en/ref/CODE/ and redirect to homepage.""" + # Store referral code in session + request.session["referral_code"] = code + + # The WebRequestMiddleware will automatically log this request with the correct path + # containing the referral code, so we don't need to create a WebRequest manually + + # Redirect to homepage + return redirect("index") + + +def index(request): + """Homepage view.""" + from django.conf import settings + + # Store referral code in session if present in URL (for backward compatibility) + ref_code = request.GET.get("ref") + + if ref_code: + request.session["referral_code"] = ref_code + # The WebRequestMiddleware will track this request automatically + # with the query parameter in the path + + # Get top referrers - including both those with referrals and those with clicks + # First, find all referral codes that have clicks in WebRequest + referral_codes_with_clicks = ( + WebRequest.objects.filter(models.Q(path__contains="/ref/") | models.Q(path__contains="?ref=")) + .values_list("path", flat=True) + .distinct() + ) + + # Extract referral codes from the paths + active_referral_codes = set() + for path in referral_codes_with_clicks: + if "/ref/" in path: + # Format: /en/ref/CODE/ + parts = path.split("/ref/") + if len(parts) > 1: + code = parts[1].strip("/") + if code: + active_referral_codes.add(code) + elif "?ref=" in path: + # Format: /?ref=CODE or ?ref=CODE&other=params + parts = path.split("?ref=") + if len(parts) > 1: + code = parts[1].split("&")[0] # Handle additional parameters + if code: + active_referral_codes.add(code) + + # Get profiles with referrals and/or clicks + profiles_with_activity = Profile.objects.filter( + models.Q(referrals__isnull=False) # Has referrals + | models.Q(referral_code__in=active_referral_codes) # Has clicks + ).distinct() + + # Annotate with signups and enrollments + top_referrers = profiles_with_activity.annotate( + total_signups=models.Count("referrals", distinct=True), + total_enrollments=models.Count( + "referrals__user__enrollments", + filter=models.Q(referrals__user__enrollments__status="approved"), + distinct=True, + ), + ).order_by("-total_signups", "-total_enrollments")[ + :10 + ] # Get more and then sort by clicks + + # Add click counts manually since WebRequest.user is a CharField, not a ForeignKey + for referrer in top_referrers: + # Look for both new format /ref/CODE/ and old format ?ref=CODE + ref_code = referrer.referral_code + clicks = WebRequest.objects.filter( + models.Q(path__contains=f"/ref/{ref_code}/") | models.Q(path__contains=f"?ref={ref_code}") + ).count() + referrer.total_clicks = clicks + + # Re-sort to include click count in ranking + top_referrers = sorted( + top_referrers, key=lambda x: (x.total_signups, x.total_enrollments, x.total_clicks), reverse=True + )[ + :3 + ] # Take top 3 after sorting + + # Get current user's profile if authenticated + profile = request.user.profile if request.user.is_authenticated else None + + # Get recent courses + featured_courses = Course.objects.filter(status="published").order_by("-created_at")[:6] + + # Get featured goods + featured_goods = Goods.objects.filter(featured=True, is_available=True).order_by("-created_at")[:3] + + # Get current challenge + current_challenge_obj = Challenge.objects.filter( + start_date__lte=timezone.now(), end_date__gte=timezone.now() + ).first() + current_challenge = [current_challenge_obj] if current_challenge_obj else [] + + # Get latest blog post + latest_post = BlogPost.objects.filter(status="published").order_by("-published_at").first() + + # Get latest success story + latest_success_story = SuccessStory.objects.filter(status="published").order_by("-published_at").first() + + # Get last two waiting room requests + latest_waiting_room_requests = WaitingRoom.objects.filter(status="open").order_by("-created_at")[:2] + + # Global virtual classroom summary for homepage CTA + global_classroom = ( + VirtualClassroom.objects.filter(name__iexact="Global Virtual Classroom", course__isnull=True) + .order_by("-created_at") + .first() + ) + global_classroom_participants = 0 + if global_classroom: + global_classroom_participants = VirtualClassroomParticipant.objects.filter(classroom=global_classroom).count() + + # Get top latest 3 leaderboard users + try: + top_leaderboard_users, user_rank = get_leaderboard(request.user, period=None, limit=3) + except Exception: + logger = logging.getLogger(__name__) + logger.error("Error getting leaderboard data", exc_info=True) + top_leaderboard_users = [] + + # Get signup form if needed + form = None + if not request.user.is_authenticated or not request.user.profile.is_teacher: + form = TeacherSignupForm() + + # Get video count and subjects for the quick add video form + video_count = EducationalVideo.objects.count() + subjects = Subject.objects.all().order_by("order", "name") + + context = { + "profile": profile, + "featured_courses": featured_courses, + "featured_products": featured_goods, + "current_challenge": current_challenge, + "latest_post": latest_post, + "latest_success_story": latest_success_story, + "latest_waiting_room_requests": latest_waiting_room_requests, + "global_classroom": global_classroom, + "global_classroom_participants": global_classroom_participants, + "top_referrers": top_referrers, + "top_leaderboard_users": top_leaderboard_users, + "form": form, + "is_debug": settings.DEBUG, + "video_count": video_count, + "subjects": subjects, + } + if request.user.is_authenticated: + user_team_goals = ( + TeamGoal.objects.filter(Q(creator=request.user) | Q(members__user=request.user)) + .distinct() + .order_by("-created_at")[:3] + ) + + team_invites = TeamInvite.objects.filter(recipient=request.user, status="pending").select_related( + "goal", "sender" + ) + + context.update( + { + "user_team_goals": user_team_goals, + "team_invites": team_invites, + } + ) + + # Add courses that the user is teaching if they have any + teaching_courses = ( + Course.objects.filter(teacher=request.user) + .annotate( + view_count=Coalesce(Sum("web_requests__count"), 0), + enrolled_students=Count("enrollments", filter=Q(enrollments__status="approved")), + ) + .order_by("-created_at") + ) + + if teaching_courses.exists(): + context.update( + { + "teaching_courses": teaching_courses, + } + ) + return render(request, "index.html", context) + + +def signup_view(request): + """Custom signup view that properly handles referral codes.""" + if request.method == "POST": + form = UserRegistrationForm(request.POST, request=request) + if form.is_valid(): + form.save(request) + return redirect("account_email_verification_sent") + else: + # Initialize form with request to get referral code from session + form = UserRegistrationForm(request=request) + + # If there's no referral code in session but it's in the URL, store it + ref_code = request.GET.get("ref") + if ref_code and not request.session.get("referral_code"): + request.session["referral_code"] = ref_code + # Reinitialize form to pick up the new session value + form = UserRegistrationForm(request=request) + + return render( + request, + "account/signup.html", + { + "form": form, + "login_url": reverse("account_login"), + }, + ) + + +@login_required +def delete_account(request): + if request.method == "POST": + form = AccountDeleteForm(request.user, request.POST) + if form.is_valid(): + if request.POST.get("confirm"): + user = request.user + user.delete() + logout(request) + messages.success(request, _("Your account has been successfully deleted.")) + return redirect("index") + else: + form.add_error(None, _("You must confirm the account deletion.")) + else: + # Get all related objects that will be deleted + deleted_objects_collector = NestedObjects(using=router.db_for_write(request.user.__class__)) + deleted_objects_collector.collect([request.user]) + + # Transform the nested structure into something more user-friendly + to_delete = deleted_objects_collector.nested() + protected = deleted_objects_collector.protected + + # Format the collected objects in a user-friendly way + model_count = { + model._meta.verbose_name_plural: len(objs) for model, objs in deleted_objects_collector.model_objs.items() + } + + # Format as a list of tuples (model name, count) + formatted_count = [(name, count) for name, count in model_count.items()] + + form = AccountDeleteForm(request.user) + # Pass the deletion info to the template + return render( + request, + "account/delete_account.html", + {"form": form, "deleted_objects": to_delete, "protected": protected, "model_count": formatted_count}, + ) + + return render(request, "account/delete_account.html", {"form": form}) + + +@login_required +def delete_waiting_room(request, waiting_room_id): + """View for deleting a waiting room.""" + waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) + + # Only allow creator to delete + if request.user != waiting_room.creator: + messages.error(request, "You don't have permission to delete this waiting room.") + return redirect("waiting_room_detail", waiting_room_id=waiting_room_id) + + if request.method == "POST": + waiting_room.delete() + messages.success(request, f"Waiting room '{waiting_room.title}' has been deleted.") + return redirect("waiting_room_list") + + return render(request, "waiting_room/confirm_delete.html", {"waiting_room": waiting_room}) + + +@login_required +def all_leaderboards(request): + """ + Display all leaderboard types on a single page. + """ + # Get cached leaderboard data or fetch fresh data + global_entries, global_rank = get_cached_leaderboard_data(request.user, None, 10, "global_leaderboard", 60 * 60) + weekly_entries, weekly_rank = get_cached_leaderboard_data(request.user, "weekly", 10, "weekly_leaderboard", 60 * 15) + monthly_entries, monthly_rank = get_cached_leaderboard_data( + request.user, "monthly", 10, "monthly_leaderboard", 60 * 30 + ) + + # Get user points and challenge entries if authenticated non-teacher + challenge_entries = [] + user_points = None + + if request.user.is_authenticated and not request.user.profile.is_teacher: + user_points = get_user_points(request.user) + challenge_entries = get_cached_challenge_entries() + + context = create_leaderboard_context( + global_entries, + weekly_entries, + monthly_entries, + challenge_entries, + global_rank, + weekly_rank, + monthly_rank, + user_points["total"], + user_points["weekly"], + user_points["monthly"], + ) + return render(request, "leaderboards/leaderboards.html", context) + else: + context = create_leaderboard_context( + global_entries, + weekly_entries, + monthly_entries, + [], + global_rank, + weekly_rank, + monthly_rank, + None, + None, + None, + ) + return render(request, "leaderboards/leaderboards.html", context) + + +@login_required +def profile(request): + if request.method == "POST": + form = ProfileUpdateForm(request.POST, request.FILES, instance=request.user) + if form.is_valid(): + form.save() # Save the form data + request.user.profile.refresh_from_db() # Refresh to load updated profile + messages.success(request, "Profile updated successfully!") + return redirect("profile") + else: + for field, errors in form.errors.items(): + for error in errors: + messages.error(request, f"Error in {field}: {error}") + else: + form = ProfileUpdateForm(instance=request.user) + + badges = UserBadge.objects.filter(user=request.user).select_related("badge") + + context = { + "form": form, + "badges": badges, + } + + # Teacher-specific stats + if request.user.profile.is_teacher: + courses = Course.objects.filter(teacher=request.user) + total_students = sum(course.enrollments.filter(status="approved").count() for course in courses) + avg_rating = 0 + total_ratings = 0 + for course in courses: + course_ratings = course.reviews.all() + if course_ratings: + avg_rating += sum(review.rating for review in course_ratings) + total_ratings += len(course_ratings) + avg_rating = round(avg_rating / total_ratings, 1) if total_ratings > 0 else 0 + context.update( + { + "courses": courses, + "total_students": total_students, + "avg_rating": avg_rating, + } + ) + # Student-specific stats + else: + enrollments = Enrollment.objects.filter(student=request.user).select_related("course") + completed_courses = enrollments.filter(status="completed").count() + total_progress = 0 + progress_count = 0 + for enrollment in enrollments: + progress, _ = CourseProgress.objects.get_or_create(enrollment=enrollment) + if progress.completion_percentage is not None: + total_progress += progress.completion_percentage + progress_count += 1 + avg_progress = round(total_progress / progress_count) if progress_count > 0 else 0 + context.update( + { + "enrollments": enrollments, + "completed_courses": completed_courses, + "avg_progress": avg_progress, + } + ) + + # Get created calendars if applicable + created_calendars = request.user.created_calendars.prefetch_related("time_slots").order_by("-created_at") + context["created_calendars"] = created_calendars + + # *** Add Discount Codes *** + discount_codes = Discount.objects.filter(user=request.user, used=False, valid_until__gte=timezone.now()) + context["discount_codes"] = discount_codes + + return render(request, "profile.html", context) + + +@login_required +def create_course(request): + if request.method == "POST": + form = CourseForm(request.POST, request.FILES) + if form.is_valid(): + course = form.save(commit=False) + course.teacher = request.user + course.status = "published" # Set status to published + course.save() + form.save_m2m() # Save many-to-many relationships + + # Handle waiting room if course was created from one + if "waiting_room_data" in request.session: + waiting_room = get_object_or_404(WaitingRoom, id=request.session["waiting_room_data"]["id"]) + + # Update waiting room status and link to course + waiting_room.status = "fulfilled" + waiting_room.fulfilled_course = course + waiting_room.save(update_fields=["status", "fulfilled_course"]) + + # Send notifications to all participants + for participant in waiting_room.participants.all(): + messages.success( + request, + f"A new course matching your request has been created: {course.title}", + extra_tags=f"course_{course.slug}", + ) + + # Clear waiting room data from session + del request.session["waiting_room_data"] + + # Redirect back to waiting room to show the update + return redirect("waiting_room_detail", waiting_room_id=waiting_room.id) + + return redirect("course_detail", slug=course.slug) + else: + form = CourseForm() + + return render(request, "courses/create.html", {"form": form}) + + +@login_required +@teacher_required +def create_course_from_waiting_room(request, waiting_room_id): + waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) + + # Ensure waiting room is open + if waiting_room.status != "open": + messages.error(request, "This waiting room is no longer open.") + return redirect("waiting_room_detail", waiting_room_id=waiting_room_id) + + # Store waiting room data in session for validation + request.session["waiting_room_data"] = { + "id": waiting_room.id, + "subject": waiting_room.subject.strip().lower(), + "topics": [t.strip().lower() for t in waiting_room.topics.split(",") if t.strip()], + } + + # Redirect to regular course creation form + return redirect(reverse("create_course")) + + +@login_required +@teacher_required +def add_featured_review(request, slug, review_id): + # Get the course and review + course = get_object_or_404(Course, slug=slug) + review = get_object_or_404(Review, id=review_id, course=course) + + # Check if the user is the course teacher + if request.user != course.teacher: + messages.error(request, "Only the course teacher can manage featured reviews.") + return redirect(reverse("course_detail", kwargs={"slug": slug})) + + # Set the is_featured field to True + review.is_featured = True + review.save() + messages.success(request, "Review has been featured.") + + # Redirect to the course detail page + url = reverse("course_detail", kwargs={"slug": slug}) + return redirect(f"{url}#course_reviews") + + +@login_required +@teacher_required +def remove_featured_review(request, slug, review_id): + # Get the course and review + course = get_object_or_404(Course, slug=slug) + review = get_object_or_404(Review, id=review_id, course=course) + + # Check if the user is the course teacher + if request.user != course.teacher: + messages.error(request, "Only the course teacher can manage featured reviews.") + return redirect(reverse("course_detail", kwargs={"slug": slug})) + + # Set the is_featured field to False + review.is_featured = False + review.save() + + # Redirect to the course detail page + url = reverse("course_detail", kwargs={"slug": slug}) + return redirect(f"{url}#course_reviews") + + +@login_required +def edit_review(request, slug, review_id): + course = get_object_or_404(Course, slug=slug) + review = get_object_or_404(Review, id=review_id, course__slug=slug) + + # Security check - only allow editing own reviews + if request.user.id != review.student.id: + messages.error(request, "You can only edit your own reviews.") + return redirect("course_detail", slug=slug) + + if request.method == "POST": + form = ReviewForm(request.POST, instance=review) + if form.is_valid(): + review = form.save(commit=False) + review.save() + messages.success(request, "Your review has been updated.") + url = reverse("course_detail", kwargs={"slug": slug}) + return redirect(f"{url}#course_reviews") + else: + form = ReviewForm(instance=review) + + context = { + "form": form, + "course": course, + "review": review, + "action": "Edit", + } + return render(request, "courses/edit_or_add_review.html", context) + + +@login_required +def delete_review(request, slug, review_id): + review = get_object_or_404(Review, id=review_id, course__slug=slug) + + # Security check - only allow deleting own reviews + if request.user.id != review.student.id: + messages.error(request, "You can only delete your own reviews.") + else: + review.delete() + messages.success(request, "Your review has been deleted.") + + url = reverse("course_detail", kwargs={"slug": slug}) + return redirect(f"{url}#course_reviews") + + +def course_detail(request, slug): + course = get_object_or_404(Course, slug=slug) + sessions = course.sessions.all().order_by("start_time") + now = timezone.now() + is_teacher = request.user == course.teacher + completed_sessions = [] + # Check if user is the teacher of this course + + # Get enrollment if user is authenticated + enrollment = None + is_enrolled = False + if request.user.is_authenticated: + enrollment = Enrollment.objects.filter(course=course, student=request.user, status="approved").first() + is_enrolled = enrollment is not None + if enrollment: + # Get completed sessions through SessionAttendance + completed_sessions = SessionAttendance.objects.filter( + student=request.user, session__course=course, status="completed" + ).values_list("session__id", flat=True) + completed_sessions = course.sessions.filter(id__in=completed_sessions) + + # Get attendance data for all enrolled students + student_attendance = {} + total_sessions = sessions.count() + + if is_teacher or is_enrolled: + for enroll in course.enrollments.all(): + attended_sessions = SessionAttendance.objects.filter( + student=enroll.student, session__course=course, status__in=["present", "late"] + ).count() + student_attendance[enroll.student.id] = {"attended": attended_sessions, "total": total_sessions} + + # Mark past sessions as completed for display + past_sessions = sessions.filter(end_time__lt=now) + future_sessions = sessions.filter(end_time__gte=now) + sessions = list(future_sessions) + list(past_sessions) # Show future sessions first + + # Calendar data + today = timezone.now().date() + + # Get the requested month from query parameters, default to current month + try: + year = int(request.GET.get("year", today.year)) + month = int(request.GET.get("month", today.month)) + current_month = today.replace(year=year, month=month, day=1) + except (ValueError, TypeError): + current_month = today.replace(day=1) + + # Calculate previous and next month + if current_month.month == 1: + prev_month = current_month.replace(year=current_month.year - 1, month=12) + else: + prev_month = current_month.replace(month=current_month.month - 1) + + if current_month.month == 12: + next_month = current_month.replace(year=current_month.year + 1, month=1) + else: + next_month = current_month.replace(month=current_month.month + 1) + + # Get the calendar for current month + cal = calendar.monthcalendar(current_month.year, current_month.month) + + # Get all session dates for this course in current month + session_dates = set( + session.start_time.date() + for session in sessions + if session.start_time.year == current_month.year and session.start_time.month == current_month.month + ) + + # Prepare calendar weeks data + calendar_weeks = [] + for week in cal: + calendar_week = [] + for day in week: + if day == 0: + calendar_week.append({"date": None, "in_month": False, "has_session": False}) + else: + date = current_month.replace(day=day) + calendar_week.append({"date": date, "in_month": True, "has_session": date in session_dates}) + calendar_weeks.append(calendar_week) + + # Check if the current user has already reviewed this course + user_review = None + if request.user.is_authenticated: + user_review = Review.objects.filter(student=request.user, course=course).first() + + # Get all reviews That not featured for this course + reviews = course.reviews.filter(is_featured=False).order_by("-created_at") + + # Get the featured review + featured_review = Review.objects.filter(is_featured=True, course=course) + + # Get all reviews sum + reviews_num = reviews.count() + featured_review.count() + + # Calculate rating distribution for visualization + rating_counts = Review.objects.filter(course=course).values("rating").annotate(count=Count("id")) + rating_distribution = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} + for item in rating_counts: + rating_distribution[item["rating"]] = item["count"] + + # Get next session for waiting room functionality + next_session = None + user_in_session_waiting_room = False + + if request.user.is_authenticated: + # Get the next upcoming session for this course + next_session = course.sessions.filter(start_time__gt=timezone.now()).order_by("start_time").first() + + # Check if user is in the session waiting room + try: + session_waiting_room = WaitingRoom.objects.get(course=course, status="open") + user_in_session_waiting_room = request.user in session_waiting_room.participants.all() + except WaitingRoom.DoesNotExist: + user_in_session_waiting_room = False + + # Build the absolute discount URL using the discount view's URL name. + from urllib.parse import urlencode + + discount_relative = reverse("apply_discount_via_referrer") + discount_params = urlencode({"course_id": course.id}) + discount_url = request.build_absolute_uri(f"{discount_relative}?{discount_params}") + + # Get active virtual classroom for the course + virtual_classroom = course.virtual_classrooms.filter(is_active=True).first() + + context = { + "course": course, + "sessions": sessions, + "now": now, + "today": today, + "is_teacher": is_teacher, + "is_enrolled": is_enrolled, + "enrollment": enrollment, + "completed_sessions": completed_sessions, + "calendar_weeks": calendar_weeks, + "current_month": current_month, + "prev_month": prev_month, + "next_month": next_month, + "student_attendance": student_attendance, + "completed_enrollment_count": course.enrollments.filter(status="completed").count(), + "in_progress_enrollment_count": course.enrollments.filter(status="in_progress").count(), + "featured_review": featured_review, + "reviews": reviews, + "user_review": user_review, + "rating_distribution": rating_distribution, + "reviews_num": reviews_num, + "discount_url": discount_url, + "virtual_classroom": virtual_classroom, + "next_session": next_session, + "user_in_session_waiting_room": user_in_session_waiting_room, + } + + return render(request, "courses/detail.html", context) + + +@login_required +def enroll_course(request, course_slug): + """Enroll in a course and handle referral rewards if applicable.""" + course = get_object_or_404(Course, slug=course_slug) + + # Check if user is already enrolled + if request.user.enrollments.filter(course=course).exists(): + messages.warning(request, "You are already enrolled in this course.") + return redirect("course_detail", slug=course_slug) + + # Check if course is full + if course.max_students and course.enrollments.count() >= course.max_students: + messages.error(request, "This course is full.") + return redirect("course_detail", slug=course_slug) + + # Check if this is the user's first enrollment and if they were referred + if not Enrollment.objects.filter(student=request.user).exists(): + if hasattr(request.user.profile, "referred_by") and request.user.profile.referred_by: + referrer = request.user.profile.referred_by + if not referrer.is_teacher: # Regular users get reward on first course enrollment + referrer.add_referral_earnings(5) + send_referral_reward_email(referrer.user, request.user, 5, "enrollment") + + # For free courses, create approved enrollment immediately + if course.price == 0: + enrollment = Enrollment.objects.create(student=request.user, course=course, status="approved") + # Send notifications for free courses + send_enrollment_confirmation(enrollment) + notify_teacher_new_enrollment(enrollment) + messages.success(request, "You have successfully enrolled in this free course.") + return redirect("course_detail", slug=course_slug) + else: + # For paid courses, create pending enrollment + enrollment = Enrollment.objects.create(student=request.user, course=course, status="pending") + messages.info(request, "Please complete the payment process to enroll in this course.") + return redirect("course_detail", slug=course_slug) + + +@login_required +def add_session(request, slug): + course = Course.objects.get(slug=slug) + if request.user != course.teacher: + messages.error(request, "Only the course teacher can add sessions!") + return redirect("course_detail", slug=slug) + + if request.method == "POST": + form = SessionForm(request.POST) + if form.is_valid(): + session = form.save(commit=False) + session.course = course + session.save() + # Send session notifications to enrolled students + notify_session_reminder(session) + messages.success(request, "Session added successfully!") + return redirect("course_detail", slug=slug) + else: + form = SessionForm() + + return render(request, "courses/session_form.html", {"form": form, "course": course, "is_edit": False}) + + +@login_required +def add_review(request, slug): + course = Course.objects.get(slug=slug) + student = request.user + + if not request.user.enrollments.filter(course=course).exists(): + messages.error(request, "Only enrolled students can review the course!") + return redirect("course_detail", slug=slug) + + if request.method == "POST": + form = ReviewForm(request.POST) + if form.is_valid(): + if Review.objects.filter(student=student, course=course).exists(): + messages.error(request, "You have already reviewed this course.") + return redirect("course_detail", slug=slug) + review = form.save(commit=False) + review.student = student + review.course = course + review.save() + messages.success(request, "Review added successfully!") + url = reverse("course_detail", kwargs={"slug": slug}) + return redirect(f"{url}#course_reviews") + else: + form = ReviewForm() + + return render(request, "courses/edit_or_add_review.html", {"form": form, "course": course, "action": "Add"}) + + +@login_required +def delete_course(request, slug): + """Handle course deletion, including image deletion.""" + course = get_object_or_404(Course, slug=slug) + + # Ensure only the course teacher can delete the course + if request.user != course.teacher: + messages.error(request, "You are not authorized to delete this course.") + return redirect("course_detail", slug=slug) + + if request.method == "POST": + + # Delete the course --> this automatically deletes the image too + course.delete() + messages.success(request, "Course deleted successfully!") + return redirect("profile") # Redirect to the profile page or another success page + + return render(request, "courses/delete_confirm.html", {"course": course}) + + +@csrf_exempt +def github_update(request): + """GitHub webhook endpoint to trigger a lightweight deploy. + + Hardening applied: + - Require POST. + - Validate X-Hub-Signature-256 using shared secret env var GITHUB_WEBHOOK_SECRET. + - Ignore unsupported events (only push by default). + - Run a safe pull + dependency install + migrate + collectstatic via a minimal bash snippet. + - Send concise status updates to Slack; avoid leaking secrets. + + NOTE: Full provisioning (packages, DB grants, etc.) still belongs to Ansible. + This endpoint just brings the already‑provisioned instance up to latest commit. + """ + if request.method != "POST": + return HttpResponseBadRequest("POST required") + + secret = getattr(settings, "GITHUB_WEBHOOK_SECRET", None) + signature = request.META.get("HTTP_X_HUB_SIGNATURE_256") + body = request.body + + if secret: + import hashlib + import hmac + + expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() + if not signature or not hmac.compare_digest(expected, signature): + send_slack_message("GitHub webhook signature mismatch") + return HttpResponseForbidden("Invalid signature") + else: + # If no secret configured, refuse (better to explicitly set one) + return HttpResponseForbidden("Webhook secret not configured") + + event = request.META.get("HTTP_X_GITHUB_EVENT", "") + if event not in {"push"}: + return HttpResponse("Ignored event", status=202) + + repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + venv_python = os.path.join(repo_dir, "venv", "bin", "python") + venv_pip = os.path.join(repo_dir, "venv", "bin", "pip") + log_lines = [] + + # Resolve git binary explicitly since systemd service Environment may override PATH. + import shutil + + git_bin = shutil.which("git") or "/usr/bin/git" + if not os.path.exists(git_bin): + msg = f"Git binary not found at resolved path: {git_bin}. Aborting lightweight deploy." + send_slack_message(msg) + return HttpResponse(status=500, content=msg) + + def run_cmd(cmd): + proc = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True) + summary = f"$ {' '.join(cmd)}\nrc={proc.returncode}\nstdout={proc.stdout[-400:]}\nstderr={proc.stderr[-400:]}" + log_lines.append(summary) + return proc.returncode == 0 + + poetry_bin = os.path.join(repo_dir, "venv", "bin", "poetry") + steps = [ + [git_bin, "fetch", "--all", "--prune"], + [git_bin, "reset", "--hard", "origin/main"], + [venv_pip, "install", "--upgrade", "pip", "wheel"], + [venv_pip, "install", "poetry==1.8.3"], + [poetry_bin, "config", "virtualenvs.create", "false", "--local"], + [poetry_bin, "install", "--only", "main", "--no-interaction", "--no-ansi"], + [venv_python, "manage.py", "migrate", "--noinput"], + [venv_python, "manage.py", "collectstatic", "--noinput"], + ] + + ok = True + for step in steps: + try: + if not run_cmd(step): + ok = False + break + except FileNotFoundError as fe: + # Capture explicit binary not found errors, send Slack, abort early. + err_msg = f"Webhook deploy step failed (missing binary): {fe}" + log_lines.append(err_msg) + send_slack_message(err_msg) + ok = False + break + + # Always attempt a reload so code changes take effect (application systemd unit) + subprocess.run(["/bin/systemctl", "restart", "education-website"], capture_output=True) + + # Slack summary (truncate to avoid long messages) + slack_msg = ( + ("Deploy success" if ok else "Deploy FAILED") + + " (github webhook)\n" + + "\n---\n" + + "\n---\n".join(line[:600] for line in log_lines[:4]) + ) + send_slack_message(slack_msg[:3500]) + + if ok: + return HttpResponse("OK") + return HttpResponse(status=500, content="Deploy failed; see logs") + + +def send_slack_message(message): + webhook_url = os.getenv("SLACK_WEBHOOK_URL") + if not webhook_url: + print("Warning: SLACK_WEBHOOK_URL not configured") + return + + payload = {"text": f"```{message}```"} + try: + response = requests.post(webhook_url, json=payload) + response.raise_for_status() # Raise exception for bad status codes + except Exception: + logger.error("Failed to send Slack message", exc_info=True) + + +def get_wsgi_last_modified_time(): + try: + return time.ctime(os.path.getmtime(settings.PA_WSGI)) + except Exception: + return "Unknown" + + +def subjects(request): + return render(request, "subjects.html") + + +def about(request): + return render(request, "about.html") + + +def waiting_rooms(request): + # Get open waiting rooms + open_rooms = WaitingRoom.objects.filter(status="open").order_by("-created_at") + + # Get fulfilled waiting rooms (ones that have associated courses) + fulfilled_rooms = WaitingRoom.objects.filter(status="fulfilled").order_by("-created_at") + + # Get rooms created by the user + user_created_rooms = ( + WaitingRoom.objects.filter(creator=request.user).order_by("-created_at") + if request.user.is_authenticated + else [] + ) + + # Get rooms the user has joined + user_joined_rooms = ( + WaitingRoom.objects.filter(participants=request.user).order_by("-created_at") + if request.user.is_authenticated + else [] + ) + + # Get topics for each room + room_topics = {} + all_rooms = list(open_rooms) + list(fulfilled_rooms) + list(user_created_rooms) + list(user_joined_rooms) + for room in all_rooms: + # Split topics string into a list + room_topics[room.id] = [t.strip() for t in room.topics.split(",")] if room.topics else [] + + context = { + "open_rooms": open_rooms, + "fulfilled_rooms": fulfilled_rooms, + "user_created_rooms": user_created_rooms, + "user_joined_rooms": user_joined_rooms, + "room_topics": room_topics, + } + + return render(request, "waiting_rooms.html", context) + + +def learn(request): + if request.method == "POST": + form = LearnForm(request.POST) + if form.is_valid(): + # Create waiting room + waiting_room = form.save(commit=False) + waiting_room.status = "open" # Set initial status + waiting_room.creator = request.user # Set the creator + + # Get topics from form and save as comma-separated string + topics = form.cleaned_data.get("topics", "") + if isinstance(topics, list): + topics = ", ".join(topics) + waiting_room.topics = topics + + waiting_room.save() + + # Redirect to waiting rooms page + return redirect("waiting_rooms") + + # Get form data + title = form.cleaned_data["title"] + description = form.cleaned_data["description"] + subject = form.cleaned_data["subject"] + topics = form.cleaned_data["topics"] + + # Prepare email content + email_subject = f"New Learning Request: {title}" + email_body = render_to_string( + "emails/learn_interest.html", + { + "title": title, + "description": description, + "subject": subject, + "topics": topics, + "user": request.user.username, + "waiting_room_id": waiting_room.id, + }, + ) + + # Send email + try: + send_mail( + email_subject, + email_body, + settings.DEFAULT_FROM_EMAIL, + [settings.DEFAULT_FROM_EMAIL], + html_message=email_body, + fail_silently=False, + ) + messages.success( + request, + "Thank you for your learning request!", + ) + return redirect("waiting_rooms") + except Exception: + logger = logging.getLogger(__name__) + logger.exception("Error sending email") + messages.error(request, "Sorry, there was an error sending your inquiry. Please try again later.") + else: + initial_data = {} + + # Handle query parameters + query = request.GET.get("query", "") + subject_param = request.GET.get("subject", "") + level = request.GET.get("level", "") + + # Try to match subject + if subject_param: + try: + subject = Subject.objects.get(name=subject_param) + initial_data["subject"] = subject.id + except Subject.DoesNotExist: + # Optionally, you could add the subject name to the description + initial_data["description"] = f"Looking for courses in {subject_param}" + + # If you want to include other parameters in the description + if query or level: + title_parts = [] + description_parts = [] + if query: + title_parts.append(f"{query}") + if level: + description_parts.append(f"Level: {level}") + + if "description" not in initial_data: + initial_data["title"] = " | ".join(title_parts) + else: + initial_data["description"] += " | " + " | ".join(description_parts) + + form = LearnForm(initial=initial_data) + return render(request, "learn.html", {"form": form}) + + +def teach(request): + """Handles the course creation process for both authenticated and unauthenticated users.""" + if request.method == "POST": + form = TeachForm(request.POST, request.FILES, user=request.user) + if form.is_valid(): + # Extract cleaned data + email = form.cleaned_data.get("email", None) + if email is None and request.user.is_authenticated: + email = request.user.email + course_title = form.cleaned_data["course_title"] + course_description = form.cleaned_data["course_description"] + course_image = form.cleaned_data.get("course_image") + preferred_session_times = form.cleaned_data["preferred_session_times"] + _ = form.cleaned_data.get("flexible_timing", False) + + # Determine the user for the course + user = None + is_new_user = False + + if request.user.is_authenticated: + # For authenticated users, always use the logged-in user + user = request.user + + # Backend validation: Check for duplicate course titles for the logged-in user + if Course.objects.filter(title__iexact=course_title, teacher=user).exists(): + form.add_error("course_title", "You already have a course with this title.") + return render(request, "teach.html", {"form": form}) + else: + # For unauthenticated users, check if the email exists or create a new user + try: + user = User.objects.get(email=email) + # User exists but isn't logged in; check if email is verified + email_address = EmailAddress.objects.filter(user=user, email=email, primary=True).first() + if email_address and email_address.verified: + messages.info( + request, + "An account with this email exists. Please login to finalize your course.", + ) + else: + # Email not verified, resend verification email + send_email_confirmation(request, user, signup=False) + messages.info( + request, + "An account with this email exists. Please verify your email to continue.", + ) + except User.DoesNotExist: + # Create a new user account + with transaction.atomic(): + # Generate a unique username + email_prefix = email.split("@")[0] + username = email_prefix + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{email_prefix}_{get_random_string(4)}_{counter}" + counter += 1 + + temp_password = get_random_string(length=8) + + # Create user with temporary password + user = User.objects.create_user(username=username, email=email, password=temp_password) + + # Update profile to be a teacher + profile, created = Profile.objects.get_or_create(user=user) + profile.is_teacher = True + profile.save() + + # Add email address for allauth verification + EmailAddress.objects.create(user=user, email=email, primary=True, verified=False) + + # Send verification email via allauth + send_email_confirmation(request, user, signup=True) + # Send welcome email with username, email, and temp password + try: + send_welcome_teach_course_email(request, user, temp_password) + except Exception: + messages.error(request, "Failed to send welcome email. Please try again.") + return render(request, "teach.html", {"form": form}) + + is_new_user = True + + # Backend validation: Check for duplicate course titles for unauthenticated users + if Course.objects.filter(title__iexact=course_title, teacher=user).exists(): + email_address = EmailAddress.objects.filter(user=user, email=email, primary=True).first() + if email_address and not email_address.verified: + # If the user is unverified, delete the existing draft and allow a new one + Course.objects.filter(title__iexact=course_title, teacher=user).delete() + else: + form.add_error("course_title", "You already have a course with this title.") + return render(request, "teach.html", {"form": form}) + + # Create a draft course + course = Course.objects.create( + title=course_title, + description=course_description, + teacher=user, + price=0, + max_students=12, + status="draft", + subject=Subject.objects.first() or Subject.objects.create(name="General"), + level="beginner", + ) + + # Handle course image if uploaded + if course_image: + course.image = course_image + course.save() + + # Create initial session if preferred time provided + if preferred_session_times: + Session.objects.create( + course=course, + title=f"{course_title} - Session 1", + description="First session of the course", + start_time=preferred_session_times, + end_time=preferred_session_times + timezone.timedelta(hours=1), + is_virtual=True, + ) + + # Handle redirection based on authentication status + if request.user.is_authenticated: + # If authenticated, mark as teacher and redirect to course setup + request.user.profile.is_teacher = True + request.user.profile.save() + messages.success( + request, f"Welcome! Your course '{course_title}' has been created. Please complete your setup." + ) + return redirect("course_detail", slug=course.slug) + else: + # Store course primary key in session for post-verification redirect + request.session["pending_course_id"] = course.pk + if is_new_user: + messages.success( + request, + "Your course has been created! " + "Please check your email for your username, password, and verification link to continue.", + ) + return redirect("account_email_verification_sent") + else: + # For existing users, redirect to login page + return redirect("account_login") + + else: + initial_data = {} + if request.GET.get("subject"): + initial_data["course_title"] = request.GET.get("subject") + form = TeachForm(initial=initial_data, user=request.user) + + return render(request, "teach.html", {"form": form}) + + +def send_welcome_teach_course_email(request, user, temp_password): + """Send welcome email with account and password setup instructions.""" + reset_url = request.build_absolute_uri(reverse("account_reset_password")) + + email_context = {"user": user, "reset_url": reset_url, "temp_password": temp_password} + + html_message = render_to_string("emails/welcome_teach_course.html", email_context) + text_message = render_to_string("emails/welcome_teach_course.txt", email_context) + + send_mail( + subject="Welcome to Your New Teaching Account.", + message=text_message, + html_message=html_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + fail_silently=False, + ) + + +def course_search(request): + query = request.GET.get("q", "") + subject = request.GET.get("subject", "") + level = request.GET.get("level", "") + min_price = request.GET.get("min_price", "") + max_price = request.GET.get("max_price", "") + sort_by = request.GET.get("sort", "-created_at") + + courses = Course.objects.filter(status="published") + + # Apply filters + if query: + courses = courses.filter( + Q(title__icontains=query) + | Q(description__icontains=query) + | Q(tags__icontains=query) + | Q(learning_objectives__icontains=query) + | Q(prerequisites__icontains=query) + | Q(teacher__username__icontains=query) + | Q(teacher__first_name__icontains=query) + | Q(teacher__last_name__icontains=query) + | Q(teacher__profile__expertise__icontains=query) + ) + + if subject: + # Handle subject filtering based on whether it's an ID (number) or a string (slug/name) + try: + # Check if subject is an integer ID + subject_id = int(subject) + courses = courses.filter(subject_id=subject_id) + except ValueError: + # If not an integer, treat as a slug or name + courses = courses.filter(Q(subject__slug=subject) | Q(subject__name__iexact=subject)) + + if level: + courses = courses.filter(level=level) + + if min_price: + try: + min_price = float(min_price) + courses = courses.filter(price__gte=min_price) + except ValueError: + pass + + if max_price: + try: + max_price = float(max_price) + courses = courses.filter(price__lte=max_price) + except ValueError: + pass + + # Annotate with average rating for sorting + courses = courses.annotate( + avg_rating=Avg("reviews__rating"), + total_students=Count("enrollments", filter=Q(enrollments__status="approved")), + ) + + # Apply sorting + if sort_by == "price": + courses = courses.order_by("price", "-avg_rating") + elif sort_by == "-price": + courses = courses.order_by("-price", "-avg_rating") + elif sort_by == "title": + courses = courses.order_by("title") + elif sort_by == "rating": + courses = courses.order_by("-avg_rating", "-total_students") + else: # Default to newest + courses = courses.order_by("-created_at") + + # Get total count before pagination + total_results = courses.count() + + # Log the search (only if query is not blank or filters are applied) + if (query and query.strip()) or subject or level or min_price or max_price: + filters = { + "subject": subject, + "level": level, + "min_price": min_price, + "max_price": max_price, + "sort_by": sort_by, + } + SearchLog.objects.create( + query=query.strip() if query else "", + results_count=total_results, + user=request.user if request.user.is_authenticated else None, + filters_applied=filters, + search_type="course", + ) + + # Pagination + paginator = Paginator(courses, 12) # Show 12 courses per page + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + is_teacher = getattr(getattr(request.user, "profile", None), "is_teacher", False) + + # initialize the user courses if founded + user_courses = set() + + # Get authenticated users courses + if request.user.is_authenticated: + if request.user.profile.is_teacher: + teacher_courses = list(Course.objects.filter(teacher=request.user)) + # Create a set of titles + user_courses = {course.title for course in teacher_courses} + else: + enrollments = Enrollment.objects.filter(student=request.user).select_related("course") + # Create a set of titles + user_courses = {course.course.title for course in enrollments} + + # Get dynamic subject choices from courses that are published + available_subjects = ( + Subject.objects.filter(courses__status="published") + .distinct() + .order_by("order", "name") + .values_list("slug", "name") + ) + + context = { + "page_obj": page_obj, + "query": query, + "subject": subject, + "level": level, + "min_price": min_price, + "max_price": max_price, + "sort_by": sort_by, + "subject_choices": list(available_subjects), + "level_choices": Course._meta.get_field("level").choices, + "total_results": total_results, + "is_teacher": is_teacher, + "user_courses": user_courses, + } + + return render(request, "courses/search.html", context) + + +@login_required +def create_payment_intent(request, slug): + """Create a payment intent for Stripe.""" + course = get_object_or_404(Course, slug=slug) + + # Prevent creating payment intents for free courses + if course.price == 0: + # Find the enrollment and update its status to approved if it's pending + enrollment = get_object_or_404(Enrollment, student=request.user, course=course) + if enrollment.status == "pending": + enrollment.status = "approved" + enrollment.save() + + # Send notifications + send_enrollment_confirmation(enrollment) + notify_teacher_new_enrollment(enrollment) + + return JsonResponse({"free_course": True, "message": "Enrollment approved for free course"}) + + # Ensure user has a pending enrollment + enrollment = get_object_or_404(Enrollment, student=request.user, course=course, status="pending") + + # Validate price is greater than zero for Stripe + if course.price <= 0: + enrollment.status = "approved" + enrollment.save() + + # Send notifications + send_enrollment_confirmation(enrollment) + notify_teacher_new_enrollment(enrollment) + + return JsonResponse({"free_course": True, "message": "Enrollment approved for free course"}) + + try: + # Create a PaymentIntent with the order amount and currency + intent = stripe.PaymentIntent.create( + amount=int(course.price * 100), # Convert to cents + currency="usd", + metadata={ + "course_id": course.id, + "user_id": request.user.id, + }, + ) + return JsonResponse({"clientSecret": intent.client_secret}) + except stripe.error.StripeError: + logger.error("Stripe error occurred", exc_info=True) + return JsonResponse({"error": "Payment processing error. Please try again."}, status=400) + except Exception: + logger.error("Unexpected error in payment intent creation", exc_info=True) + return JsonResponse({"error": "An internal error occurred. Please try again."}, status=500) + + +@csrf_exempt +def stripe_webhook(request): + """Stripe webhook endpoint for handling payment events.""" + payload = request.body + sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") + + try: + event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_WEBHOOK_SECRET) + except ValueError: + # Invalid payload + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError: + # Invalid signature + return HttpResponse(status=400) + + if event.type == "payment_intent.succeeded": + payment_intent = event.data.object + handle_successful_payment(payment_intent) + elif event.type == "payment_intent.payment_failed": + payment_intent = event.data.object + handle_failed_payment(payment_intent) + + return HttpResponse(status=200) + + +def handle_successful_payment(payment_intent): + """Handle successful payment by enrolling the user in the course.""" + # Get metadata from the payment intent + course_id = payment_intent.metadata.get("course_id") + user_id = payment_intent.metadata.get("user_id") + + # Create enrollment and payment records + course = Course.objects.get(id=course_id) + user = User.objects.get(id=user_id) + + # Create enrollment with pending status + enrollment = Enrollment.objects.get_or_create(student=user, course=course, defaults={"status": "pending"})[0] + + # Update status to approved after successful payment + enrollment.status = "approved" + enrollment.save() + + # Create a payment record for tracking teacher earnings + # Convert amount from cents to dollars + amount = Decimal(str(payment_intent.amount)) / 100 + Payment.objects.create( + enrollment=enrollment, + amount=amount, + currency=payment_intent.currency.upper(), + stripe_payment_intent_id=payment_intent.id, + status="completed", + ) + + # Send notifications + send_enrollment_confirmation(enrollment) + notify_teacher_new_enrollment(enrollment) + + +def handle_failed_payment(payment_intent): + """Handle failed payment.""" + course_id = payment_intent.metadata.get("course_id") + user_id = payment_intent.metadata.get("user_id") + + try: + course = Course.objects.get(id=course_id) + user = User.objects.get(id=user_id) + enrollment = Enrollment.objects.get(student=user, course=course) + enrollment.status = "pending" + enrollment.save() + except (Course.DoesNotExist, User.DoesNotExist, Enrollment.DoesNotExist): + pass # Log error or handle appropriately + + +@login_required +def update_course(request, slug): + course = get_object_or_404(Course, slug=slug) + if request.user != course.teacher: + return HttpResponseForbidden() + + if request.method == "POST": + form = CourseForm(request.POST, request.FILES, instance=course) + if form.is_valid(): + form.save() + messages.success(request, "Course updated successfully!") + return redirect("course_detail", slug=course.slug) + else: + form = CourseForm(instance=course) + + return render(request, "courses/update.html", {"form": form, "course": course}) + + +@login_required +def mark_session_attendance(request, session_id): + session = Session.objects.get(id=session_id) + if request.user != session.course.teacher: + messages.error(request, "Only the course teacher can mark attendance!") + return redirect("course_detail", slug=session.course.slug) + + if request.method == "POST": + for student_id, status in request.POST.items(): + if student_id.startswith("student_"): + student_id = student_id.replace("student_", "") + student = User.objects.get(id=student_id) + attendance, created = SessionAttendance.objects.update_or_create( + session=session, student=student, defaults={"status": status} + ) + messages.success(request, "Attendance marked successfully!") + return redirect("course_detail", slug=session.course.slug) + + enrollments = session.course.enrollments.filter(status="approved") + attendances = {att.student_id: att.status for att in session.attendances.all()} + + context = { + "session": session, + "enrollments": enrollments, + "attendances": attendances, + } + return render(request, "courses/mark_attendance.html", context) + + +@login_required +def mark_session_completed(request, session_id): + session = Session.objects.get(id=session_id) + enrollment = request.user.enrollments.get(course=session.course) + + if enrollment.status != "approved": + messages.error(request, "You must be enrolled in the course to mark sessions as completed!") + return redirect("course_detail", slug=session.course.slug) + + progress, created = CourseProgress.objects.get_or_create(enrollment=enrollment) + progress.completed_sessions.add(session) + + # Check for achievements + if progress.completion_percentage == 100: + Achievement.objects.get_or_create( + student=request.user, + course=session.course, + achievement_type="completion", + defaults={ + "title": "Course Completed!", + "description": f"Completed all sessions in {session.course.title}", + }, + ) + + if progress.attendance_rate == 100: + Achievement.objects.get_or_create( + student=request.user, + course=session.course, + achievement_type="attendance", + defaults={ + "title": "Perfect Attendance!", + "description": f"Attended all sessions in {session.course.title}", + }, + ) + + messages.success(request, "Session marked as completed!") + return redirect("course_detail", slug=session.course.slug) + + +@login_required +def award_achievement(request): + try: + profile = request.user.profile + if not profile.is_teacher: + messages.error(request, "You do not have permission to award achievements.") + return redirect("teacher_dashboard") + except Profile.DoesNotExist: + messages.error(request, "Profile not found.") + return redirect("teacher_dashboard") + + if request.method == "POST": + form = AwardAchievementForm(request.POST, teacher=request.user) + if form.is_valid(): + Achievement.objects.create( + student=form.cleaned_data["student"], + course=form.cleaned_data["course"], + achievement_type=form.cleaned_data["achievement_type"], + title=form.cleaned_data["title"], + description=form.cleaned_data["description"], + badge_icon=form.cleaned_data["badge_icon"], + ) + messages.success( + request, + f'Achievement "{form.cleaned_data["title"]}" awarded to {form.cleaned_data["student"].username}.', + ) + return redirect("teacher_dashboard") + else: + # Show an error message if the form is invalid + messages.error(request, "There was an error in the form submission. Please check the form and try again.") + else: + form = AwardAchievementForm(teacher=request.user) + return render(request, "award_achievement.html", {"form": form}) + + +@login_required +def student_progress(request, enrollment_id): + enrollment = Enrollment.objects.get(id=enrollment_id) + + if request.user != enrollment.student and request.user != enrollment.course.teacher: + messages.error(request, "You don't have permission to view this progress!") + return redirect("course_detail", slug=enrollment.course.slug) + + progress, created = CourseProgress.objects.get_or_create(enrollment=enrollment) + achievements = Achievement.objects.filter(student=enrollment.student, course=enrollment.course) + + past_sessions = enrollment.course.sessions.filter(start_time__lt=timezone.now()) + upcoming_sessions = enrollment.course.sessions.filter(start_time__gte=timezone.now()) + + context = { + "enrollment": enrollment, + "progress": progress, + "achievements": achievements, + "past_sessions": past_sessions, + "upcoming_sessions": upcoming_sessions, + "stripe_public_key": ( + settings.STRIPE_PUBLISHABLE_KEY if enrollment.status == "pending" and enrollment.course.price > 0 else None + ), + } + return render(request, "courses/student_progress.html", context) + + +@login_required +def course_progress_overview(request, slug): + course = Course.objects.get(slug=slug) + if request.user != course.teacher: + messages.error(request, "Only the course teacher can view the progress overview!") + return redirect("course_detail", slug=slug) + + enrollments = course.enrollments.filter(status="approved") + progress_data = [] + + for enrollment in enrollments: + progress, created = CourseProgress.objects.get_or_create(enrollment=enrollment) + attendance_data = ( + SessionAttendance.objects.filter(student=enrollment.student, session__course=course) + .values("status") + .annotate(count=models.Count("status")) + ) + + progress_data.append( + { + "enrollment": enrollment, + "progress": progress, + "attendance": attendance_data, + } + ) + + context = { + "course": course, + "progress_data": progress_data, + } + return render(request, "courses/progress_overview.html", context) + + +@login_required +def upload_material(request, slug): + course = get_object_or_404(Course, slug=slug) + if request.user != course.teacher: + return HttpResponseForbidden("You are not authorized to upload materials for this course.") + + if request.method == "POST": + form = CourseMaterialForm(request.POST, request.FILES, course=course) + if form.is_valid(): + material = form.save(commit=False) + material.course = course + material.save() + messages.success(request, "Course material uploaded successfully!") + return redirect("course_detail", slug=course.slug) + else: + form = CourseMaterialForm(course=course) + + return render(request, "courses/upload_material.html", {"form": form, "course": course}) + + +@login_required +def delete_material(request, slug, material_id): + material = get_object_or_404(CourseMaterial, id=material_id, course__slug=slug) + if request.user != material.course.teacher: + return HttpResponseForbidden("You are not authorized to delete this material.") + + if request.method == "POST": + material.delete() + messages.success(request, "Course material deleted successfully!") + return redirect("course_detail", slug=slug) + + return render(request, "courses/delete_material_confirm.html", {"material": material}) + + +@login_required +def download_material(request, slug, material_id): + material = get_object_or_404(CourseMaterial, id=material_id, course__slug=slug) + if not material.is_downloadable and request.user != material.course.teacher: + return HttpResponseForbidden("This material is not available for download.") + + try: + return FileResponse(material.file, as_attachment=True) + except FileNotFoundError: + messages.error(request, "The requested file could not be found.") + return redirect("course_detail", slug=slug) + + +@login_required +@teacher_required +def course_marketing(request, slug): + """View for managing course marketing and promotions.""" + course = get_object_or_404(Course, slug=slug, teacher=request.user) + + if request.method == "POST": + action = request.POST.get("action") + + if action == "send_promotional_emails": + send_course_promotion_email( + course=course, + subject=f"New Course Recommendation: {course.title}", + template_name="course_promotion", + ) + messages.success(request, "Promotional emails have been sent successfully.") + + elif action == "generate_social_content": + social_content = generate_social_share_content(course) + return JsonResponse({"social_content": social_content}) + + # Get analytics and recommendations + analytics = get_course_analytics(course) + recommendations = get_promotion_recommendations(course) + + context = { + "course": course, + "analytics": analytics, + "recommendations": recommendations, + } + + return render(request, "courses/marketing.html", context) + + +@login_required +@teacher_required +def course_analytics(request, slug): + """View for displaying detailed course analytics.""" + course = get_object_or_404(Course, slug=slug, teacher=request.user) + analytics = get_course_analytics(course) + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"analytics": analytics}) + + context = { + "course": course, + "analytics": analytics, + } + + return render(request, "courses/analytics.html", context) + + +@login_required +def calendar_feed(request): + """Generate and serve an iCal feed of the user's course sessions.""" + + response = HttpResponse(generate_ical_feed(request.user), content_type="text/calendar") + response["Content-Disposition"] = f'attachment; filename="{settings.SITE_NAME}-schedule.ics"' + return response + + +@login_required +def calendar_links(request, session_id): + """Get calendar links for a specific session.""" + + session = get_object_or_404(Session, id=session_id) + + # Check if user has access to this session + if not ( + request.user == session.course.teacher + or request.user.enrollments.filter(course=session.course, status="approved").exists() + ): + return HttpResponseForbidden("You don't have access to this session.") + + links = { + "google": generate_google_calendar_link(session), + "outlook": generate_outlook_calendar_link(session), + } + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"links": links}) + + return render( + request, + "courses/calendar_links.html", + { + "session": session, + "calendar_links": links, + }, + ) + + +def forum_categories(request): + """Display all forum categories.""" + categories = ForumCategory.objects.all() + return render(request, "web/forum/categories.html", {"categories": categories}) + + +def forum_category(request, slug): + """Display topics in a specific category.""" + category = get_object_or_404(ForumCategory, slug=slug) + topics = category.topics.all() + categories = ForumCategory.objects.all() + return render( + request, "web/forum/category.html", {"category": category, "topics": topics, "categories": categories} + ) + + +def forum_topic(request, category_slug, topic_id): + """Display a forum topic and its replies.""" + topic = get_object_or_404(ForumTopic, id=topic_id, category__slug=category_slug) + categories = ForumCategory.objects.all() + + # Get view count from WebRequest model + view_count = ( + WebRequest.objects.filter(path=request.path).aggregate(total_views=models.Sum("count"))["total_views"] or 0 + ) + topic.views = view_count + topic.save() + + # Handle POST requests for replies, voting, and deletion + if request.method == "POST": + action = request.POST.get("action") + + if action == "add_reply" and request.user.is_authenticated: + content = request.POST.get("content") + if content: + ForumReply.objects.create(topic=topic, author=request.user, content=content) + messages.success(request, "Reply added successfully.") + return redirect("forum_topic", category_slug=category_slug, topic_id=topic_id) + + elif action == "delete_reply" and request.user.is_authenticated: + reply_id = request.POST.get("reply_id") + reply = get_object_or_404(ForumReply, id=reply_id, author=request.user) + reply.delete() + messages.success(request, "Reply deleted successfully.") + return redirect("forum_topic", category_slug=category_slug, topic_id=topic_id) + + elif action == "delete_topic" and request.user == topic.author: + topic.delete() + messages.success(request, "Topic deleted successfully.") + return redirect("forum_category", slug=category_slug) + + # Fetch replies after POST handling + replies = topic.replies.select_related("author").order_by("created_at") + + # Votes handling + if request.user.is_authenticated: + user_topic_vote = topic.user_vote(request.user) + user_reply_votes = {reply.id: reply.user_vote(request.user) for reply in replies} + else: + user_topic_vote = None + user_reply_votes = {} + + return render( + request, + "web/forum/topic.html", + { + "topic": topic, + "replies": replies, + "categories": categories, + "user_topic_vote": user_topic_vote, + "user_reply_votes": user_reply_votes, + }, + ) + + +@login_required +def create_topic(request, category_slug): + """Create a new forum topic.""" + category = get_object_or_404(ForumCategory, slug=category_slug) + categories = ForumCategory.objects.all() + + if request.method == "POST": + form = ForumTopicForm(request.POST) + if form.is_valid(): + topic = ForumTopic.objects.create( + category=category, + author=request.user, + title=form.cleaned_data["title"], + content=form.cleaned_data["content"], + github_issue_url=form.cleaned_data.get("github_issue_url", ""), + github_milestone_url=form.cleaned_data.get("github_milestone_url", ""), + ) + messages.success(request, "Topic created successfully!") + return redirect("forum_topic", category_slug=category_slug, topic_id=topic.id) + else: + form = ForumTopicForm() + + return render( + request, "web/forum/create_topic.html", {"category": category, "form": form, "categories": categories} + ) + + +@login_required +def peer_connections(request): + """Display user's peer connections.""" + sent_connections = request.user.sent_connections.all() + received_connections = request.user.received_connections.all() + return render( + request, + "web/peer/connections.html", + { + "sent_connections": sent_connections, + "received_connections": received_connections, + }, + ) + + +@login_required +def send_connection_request(request, user_id): + """Send a peer connection request.""" + receiver = get_object_or_404(User, id=user_id) + + if request.user == receiver: + messages.error(request, "You cannot connect with yourself!") + return redirect("peer_connections") + + connection, created = PeerConnection.objects.get_or_create( + sender=request.user, receiver=receiver, defaults={"status": "pending"} + ) + + if created: + messages.success(request, f"Connection request sent to {receiver.username}!") + else: + messages.info(request, f"Connection request already sent to {receiver.username}.") + + return redirect("peer_connections") + + +@login_required +def handle_connection_request(request, connection_id, action): + """Accept or reject a peer connection request.""" + connection = get_object_or_404(PeerConnection, id=connection_id, receiver=request.user, status="pending") + + if action == "accept": + connection.status = "accepted" + messages.success(request, f"Connection with {connection.sender.username} accepted!") + elif action == "reject": + connection.status = "rejected" + messages.info(request, f"Connection with {connection.sender.username} rejected.") + + connection.save() + return redirect("peer_connections") + + +@login_required +def peer_messages(request, user_id): + """Display and handle messages with a peer.""" + peer = get_object_or_404(User, id=user_id) + + # Check if users are connected + connection = PeerConnection.objects.filter( + (Q(sender=request.user, receiver=peer) | Q(sender=peer, receiver=request.user)), + status="accepted", + ).first() + + if not connection: + messages.error(request, "You must be connected with this user to send messages.") + return redirect("peer_connections") + + if request.method == "POST": + content = request.POST.get("content") + if content: + PeerMessage.objects.create(sender=request.user, receiver=peer, content=content) + messages.success(request, "Message sent!") + + # Get conversation messages + messages_list = PeerMessage.objects.filter( + (Q(sender=request.user, receiver=peer) | Q(sender=peer, receiver=request.user)) + ).order_by("created_at") + + # Mark received messages as read + messages_list.filter(sender=peer, receiver=request.user, is_read=False).update(is_read=True) + + return render(request, "web/peer/messages.html", {"peer": peer, "messages": messages_list}) + + +@login_required +def study_groups(request, course_id): + """Display study groups for a course.""" + course = get_object_or_404(Course, id=course_id) + groups = course.study_groups.all() + + if request.method == "POST": + name = request.POST.get("name") + description = request.POST.get("description") + max_members = request.POST.get("max_members", 10) + is_private = request.POST.get("is_private", False) + + if name and description: + group = StudyGroup.objects.create( + course=course, + creator=request.user, + name=name, + description=description, + max_members=max_members, + is_private=is_private, + ) + group.members.add(request.user) + messages.success(request, "Study group created successfully!") + return redirect("study_group_detail", group_id=group.id) + + return render(request, "web/study/groups.html", {"course": course, "groups": groups}) + + +@login_required +def study_group_detail(request, group_id): + """Display study group details and handle join/leave requests.""" + group = get_object_or_404(StudyGroup, id=group_id) + + if request.method == "POST": + action = request.POST.get("action") + + if action == "join": + if group.members.count() >= group.max_members: + messages.error(request, "This group is full!") + else: + group.members.add(request.user) + messages.success(request, f"You have joined {group.name}!") + + elif action == "leave": + if request.user == group.creator: + messages.error(request, "Group creator cannot leave the group!") + else: + group.members.remove(request.user) + messages.info(request, f"You have left {group.name}.") + + return render(request, "web/study/group_detail.html", {"group": group}) + + +# API Views +@login_required +def api_course_list(request): + """API endpoint for listing courses.""" + courses = Course.objects.filter(status="published") + data = [ + { + "id": course.id, + "title": course.title, + "description": course.description, + "teacher": course.teacher.username, + "price": str(course.price), + "subject": course.subject, + "level": course.level, + "slug": course.slug, + } + for course in courses + ] + return JsonResponse(data, safe=False) + + +@login_required +@teacher_required +def api_course_create(request): + """API endpoint for creating a course.""" + if request.method != "POST": + return JsonResponse({"error": "Only POST method is allowed"}, status=405) + + data = json.loads(request.body) + course = Course.objects.create( + teacher=request.user, + title=data["title"], + description=data["description"], + learning_objectives=data["learning_objectives"], + prerequisites=data.get("prerequisites", ""), + price=data["price"], + max_students=data["max_students"], + subject=data["subject"], + level=data["level"], + ) + return JsonResponse( + { + "id": course.id, + "title": course.title, + "slug": course.slug, + }, + status=201, + ) + + +@login_required +def api_course_detail(request, slug): + """API endpoint for course details.""" + course = get_object_or_404(Course, slug=slug) + data = { + "id": course.id, + "title": course.title, + "description": course.description, + "teacher": course.teacher.username, + "price": str(course.price), + "subject": course.subject, + "level": course.level, + "prerequisites": course.prerequisites, + "learning_objectives": course.learning_objectives, + "max_students": course.max_students, + "available_spots": course.available_spots, + "average_rating": course.average_rating, + } + return JsonResponse(data) + + +@login_required +def api_enroll(request, course_slug): + """API endpoint for course enrollment.""" + if request.method != "POST": + return JsonResponse({"error": "Only POST method is allowed"}, status=405) + + course = get_object_or_404(Course, slug=course_slug) + if request.user.enrollments.filter(course=course).exists(): + return JsonResponse({"error": "Already enrolled"}, status=400) + + enrollment = Enrollment.objects.create( + student=request.user, + course=course, + status="pending", + ) + return JsonResponse( + { + "id": enrollment.id, + "status": enrollment.status, + }, + status=201, + ) + + +@login_required +def api_enrollments(request): + """API endpoint for listing user enrollments.""" + enrollments = request.user.enrollments.all() + data = [ + { + "id": enrollment.id, + "course": { + "id": enrollment.course.id, + "title": enrollment.course.title, + "slug": enrollment.course.slug, + }, + "status": enrollment.status, + "enrollment_date": enrollment.enrollment_date.isoformat(), + } + for enrollment in enrollments + ] + return JsonResponse(data, safe=False) + + +@login_required +def api_session_list(request, course_slug): + """API endpoint for listing course sessions.""" + course = get_object_or_404(Course, slug=course_slug) + sessions = course.sessions.all() + data = [ + { + "id": session.id, + "title": session.title, + "description": session.description, + "start_time": session.start_time.isoformat(), + "end_time": session.end_time.isoformat(), + "is_virtual": session.is_virtual, + } + for session in sessions + ] + return JsonResponse(data, safe=False) + + +@login_required +def api_session_detail(request, pk): + """API endpoint for session details.""" + session = get_object_or_404(Session, pk=pk) + data = { + "id": session.id, + "title": session.title, + "description": session.description, + "start_time": session.start_time.isoformat(), + "end_time": session.end_time.isoformat(), + "is_virtual": session.is_virtual, + "meeting_link": session.meeting_link if session.is_virtual else None, + "location": session.location if not session.is_virtual else None, + } + return JsonResponse(data) + + +@login_required +def api_forum_topic_create(request): + """API endpoint for creating forum topics.""" + if request.method != "POST": + return JsonResponse({"error": "Only POST method is allowed"}, status=405) + + data = json.loads(request.body) + category = get_object_or_404(ForumCategory, id=data["category"]) + topic = ForumTopic.objects.create( + title=data["title"], + content=data["content"], + category=category, + author=request.user, + ) + return JsonResponse( + { + "id": topic.id, + "title": topic.title, + }, + status=201, + ) + + +@login_required +def api_forum_reply_create(request): + """API endpoint for creating forum replies.""" + if request.method != "POST": + return JsonResponse({"error": "Only POST method is allowed"}, status=405) + + data = json.loads(request.body) + topic = get_object_or_404(ForumTopic, id=data["topic"]) + reply = ForumReply.objects.create( + topic=topic, + content=data["content"], + author=request.user, + ) + return JsonResponse( + { + "id": reply.id, + "content": reply.content, + }, + status=201, + ) + + +@login_required +def session_detail(request, session_id): + try: + session = get_object_or_404(Session, id=session_id) + + # Check access rights + if not ( + request.user == session.course.teacher + or request.user.enrollments.filter(course=session.course, status="approved").exists() + ): + return HttpResponseForbidden("You don't have access to this session") + + # Get next session for waiting room functionality + next_session = None + user_in_session_waiting_room = False + + if request.user.is_authenticated: + # Get the next upcoming session for this course + next_session = session.course.sessions.filter(start_time__gt=timezone.now()).order_by("start_time").first() + + # Check if user is in the session waiting room + try: + session_waiting_room = WaitingRoom.objects.get(course=session.course, status="open") + user_in_session_waiting_room = request.user in session_waiting_room.participants.all() + except WaitingRoom.DoesNotExist: + user_in_session_waiting_room = False + + context = { + "session": session, + "is_teacher": request.user == session.course.teacher, + "now": timezone.now(), + "next_session": next_session, + "user_in_session_waiting_room": user_in_session_waiting_room, + } + + return render(request, "web/study/session_detail.html", context) + + except Session.DoesNotExist: + messages.error(request, "Session not found") + return redirect("course_search") + except Exception as e: + if settings.DEBUG: + raise e + messages.error(request, "An error occurred while loading the session") + return redirect("index") + + +def blog_list(request): + blog_posts = BlogPost.objects.filter(status="published").order_by("-published_at") + tags = BlogPost.objects.values_list("tags", flat=True).distinct() + # Split comma-separated tags and get unique values + unique_tags = sorted(set(tag.strip() for tags_str in tags if tags_str for tag in tags_str.split(","))) + + return render(request, "blog/list.html", {"blog_posts": blog_posts, "tags": unique_tags}) + + +def blog_tag(request, tag): + """View for filtering blog posts by tag.""" + blog_posts = BlogPost.objects.filter(status="published", tags__icontains=tag).order_by("-published_at") + tags = BlogPost.objects.values_list("tags", flat=True).distinct() + # Split comma-separated tags and get unique values + unique_tags = sorted(set(tag.strip() for tags_str in tags if tags_str for tag in tags_str.split(","))) + + return render(request, "blog/list.html", {"blog_posts": blog_posts, "tags": unique_tags, "current_tag": tag}) + + +@login_required +def create_blog_post(request): + if request.method == "POST": + form = BlogPostForm(request.POST, request.FILES) + if form.is_valid(): + post = form.save(commit=False) + post.author = request.user + post.save() + messages.success(request, "Blog post created successfully!") + return redirect("blog_detail", slug=post.slug) + else: + form = BlogPostForm() + + return render(request, "blog/create.html", {"form": form}) + + +def blog_detail(request, slug): + """Display a blog post and its comments.""" + post = get_object_or_404(BlogPost, slug=slug, status="published") + comments = post.comments.filter(is_approved=True).order_by("created_at") + + if request.method == "POST": + if not request.user.is_authenticated: + messages.error(request, "Please log in to comment.") + return redirect("account_login") + + comment_content = request.POST.get("content") + if comment_content: + comment = BlogComment.objects.create( + post=post, author=request.user, content=comment_content, is_approved=True # Auto-approve for now + ) + messages.success(request, f"Comment #{comment.id} added successfully!") + return redirect("blog_detail", slug=slug) + + # Get view count from WebRequest + view_count = WebRequest.objects.filter(path=request.path).aggregate(total_views=Sum("count"))["total_views"] or 0 + + context = { + "post": post, + "comments": comments, + "view_count": view_count, + } + return render(request, "blog/detail.html", context) + + +@login_required +def student_dashboard(request): + """ + Dashboard view for students showing enrollments, progress, upcoming sessions, learning streak, + and an Achievements section. + """ + + # Update the learning streak. + streak, created = LearningStreak.objects.get_or_create(user=request.user) + streak.update_streak() + + enrollments = Enrollment.objects.filter(student=request.user).select_related("course") + upcoming_sessions = Session.objects.filter( + course__enrollments__student=request.user, start_time__gt=timezone.now() + ).order_by("start_time")[:5] + + progress_data = [] + total_progress = 0 + for enrollment in enrollments: + progress, _ = CourseProgress.objects.get_or_create(enrollment=enrollment) + progress_data.append( + { + "enrollment": enrollment, + "progress": progress, + } + ) + total_progress += progress.completion_percentage + + avg_progress = round(total_progress / len(progress_data)) if progress_data else 0 + + # Query achievements for the user. + achievements = Achievement.objects.filter(student=request.user).order_by("-awarded_at") + + context = { + "enrollments": enrollments, + "upcoming_sessions": upcoming_sessions, + "progress_data": progress_data, + "avg_progress": avg_progress, + "streak": streak, + "achievements": achievements, + } + return render(request, "dashboard/student.html", context) + + +@login_required +@teacher_required +def teacher_dashboard(request): + """Dashboard view for teachers showing their courses, student progress, and upcoming sessions. + + The earnings calculation is based on completed payment records, not just enrollments. + This ensures that earnings accurately reflect actual transactions rather than just the + number of enrolled students. Each payment has a 90% teacher commission rate applied. + """ + courses = Course.objects.filter(teacher=request.user) + upcoming_sessions = Session.objects.filter(course__teacher=request.user, start_time__gt=timezone.now()).order_by( + "start_time" + )[:5] + + # Get enrollment and progress stats for each course + course_stats = [] + total_students = 0 + total_completed = 0 + total_earnings = Decimal("0.00") + for course in courses: + enrollments = course.enrollments.filter(status="approved") + course_total_students = enrollments.count() + course_completed = enrollments.filter(status="completed").count() + total_students += course_total_students + total_completed += course_completed + + # Calculate earnings based on completed payments instead of enrollment count + # Each payment has an amount field which represents the actual amount paid + # We apply the teacher's commission rate (90% by default, 10% platform fee) + course_earnings = Decimal("0.00") + for enrollment in enrollments: + # Get all completed payments for this enrollment + completed_payments = enrollment.payments.filter(status="completed") + for payment in completed_payments: + # Apply the 90% teacher commission + course_earnings += payment.amount * Decimal("0.9") + + total_earnings += course_earnings + course_stats.append( + { + "course": course, + "total_students": course_total_students, + "completed": course_completed, + "completion_rate": (course_completed / course_total_students * 100) if course_total_students > 0 else 0, + "earnings": course_earnings, + } + ) + + # Get the teacher's storefront if it exists + storefront = Storefront.objects.filter(teacher=request.user).first() + + context = { + "courses": courses, + "upcoming_sessions": upcoming_sessions, + "course_stats": course_stats, + "total_students": total_students, + "completion_rate": (total_completed / total_students * 100) if total_students > 0 else 0, + "total_earnings": round(total_earnings, 2), + "storefront": storefront, + } + return render(request, "dashboard/teacher.html", context) + + +def custom_404(request, exception): + """Custom 404 error handler""" + return render(request, "404.html", status=404) + + +# def custom_500(request): +# """Custom 500 error handler""" +# return render(request, "500.html", status=500) + + +def custom_429(request, exception=None): + """Custom 429 error page.""" + return render(request, "429.html", status=429) + + +def cart_view(request): + """View the shopping cart.""" + cart = get_or_create_cart(request) + return render(request, "cart/cart.html", {"cart": cart, "stripe_public_key": settings.STRIPE_PUBLISHABLE_KEY}) + + +def add_course_to_cart(request, course_id): + """Add a course to the cart.""" + course = get_object_or_404(Course, id=course_id) + cart = get_or_create_cart(request) + + # Try to get or create the cart item + cart_item, created = CartItem.objects.get_or_create(cart=cart, course=course, defaults={"session": None}) + + if created: + messages.success(request, f"{course.title} added to cart.") + else: + messages.info(request, f"{course.title} is already in your cart.") + + return redirect("cart_view") + + +def add_session_to_cart(request, session_id): + """Add an individual session to the cart.""" + session = get_object_or_404(Session, id=session_id) + cart = get_or_create_cart(request) + + # Try to get or create the cart item + cart_item, created = CartItem.objects.get_or_create(cart=cart, session=session, defaults={"course": None}) + + if created: + messages.success(request, f"{session.title} added to cart.") + else: + messages.info(request, f"{session.title} is already in your cart.") + + return redirect("cart_view") + + +def remove_from_cart(request, item_id): + """Remove an item from the shopping cart.""" + cart = get_or_create_cart(request) + item = get_object_or_404(CartItem, id=item_id, cart=cart) + item.delete() + messages.success(request, "Item removed from cart.") + return redirect("cart_view") + + +def create_cart_payment_intent(request): + """Create a payment intent for the entire cart.""" + cart = get_or_create_cart(request) + + if not cart.items.exists(): + return JsonResponse({"error": "Cart is empty"}, status=400) + + # Handle free cart (all items are free courses) + if cart.total == 0: + return JsonResponse({"free_cart": True, "message": "Cart contains only free items"}) + + try: + # Create a PaymentIntent with the cart total + intent = stripe.PaymentIntent.create( + amount=int(cart.total * 100), # Convert to cents + currency="usd", + metadata={ + "cart_id": cart.id, + "user_id": request.user.id if request.user.is_authenticated else None, + "session_key": request.session.session_key if not request.user.is_authenticated else None, + }, + ) + return JsonResponse({"clientSecret": intent.client_secret}) + except Exception as e: + return JsonResponse({"error": str(e)}, status=403) + + +@login_required +def free_cart_checkout(request): + """Handle checkout for cart with only free items.""" + if request.method != "POST": + messages.error(request, "Invalid request method.") + return redirect("cart_view") + + cart = get_or_create_cart(request) + + if not cart.items.exists(): + messages.error(request, "Cart is empty.") + return redirect("cart_view") + + # Verify that cart total is 0 (all items are free) + if cart.total != 0: + messages.error(request, "Cart contains paid items. Please use regular checkout.") + return redirect("cart_view") + + user = request.user + enrollments = [] + session_enrollments = [] + goods_items = [] + + # Create the Order + order = Order.objects.create( + user=user, + total_price=0, + status="completed", + shipping_address=None, + terms_accepted=True, + ) + + # Process enrollments + for item in cart.items.all(): + if item.course: + # Create enrollment for free course + enrollment = Enrollment.objects.create(student=user, course=item.course, status="approved") + enrollments.append(enrollment) + + # Send notifications + send_enrollment_confirmation(enrollment) + notify_teacher_new_enrollment(enrollment) + + elif item.session: + # Process individual session enrollments + session_enrollment = SessionEnrollment.objects.create(student=user, session=item.session, status="approved") + session_enrollments.append(session_enrollment) + + elif item.goods: + # Free goods (price = 0) + goods_items.append(item) + OrderItem.objects.create( + order=order, + goods=item.goods, + quantity=1, + price_at_purchase=0, + discounted_price_at_purchase=0, + ) + + # Clear the cart + cart.items.all().delete() + + # Render the receipt page + return render( + request, + "cart/receipt.html", + { + "payment_intent_id": None, + "order_date": timezone.now(), + "user": user, + "enrollments": enrollments, + "session_enrollments": session_enrollments, + "goods_items": goods_items, + "total": 0, + "order": order, + "shipping_address": None, + }, + ) + + +def checkout_success(request): + """Handle successful checkout and payment confirmation.""" + payment_intent_id = request.GET.get("payment_intent") + + if not payment_intent_id: + messages.error(request, "No payment information found.") + return redirect("cart_view") + + try: + # Verify the payment intent + payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id) + + if payment_intent.status != "succeeded": + messages.error(request, "Payment was not successful.") + return redirect("cart_view") + + cart = get_or_create_cart(request) + + if not cart.items.exists(): + messages.error(request, "Cart is empty.") + return redirect("cart_view") + + # Handle guest checkout + if not request.user.is_authenticated: + email = payment_intent.receipt_email + if not email: + messages.error(request, "No email provided for guest checkout.") + return redirect("cart_view") + + # Create a new user account with transaction and better username generation + with transaction.atomic(): + # Generate a random username without using the email + timestamp = timezone.now().strftime("%Y%m%d%H%M%S") + username = f"user_{timestamp}" + + # In the unlikely case of a collision, append random string + while User.objects.filter(username=username).exists(): + username = f"user_{timestamp}_{get_random_string(6)}" + + # Create the user + user = User.objects.create_user( + username=username, + email=email, + password=get_random_string(length=32), # Random password for reset + ) + + # Associate the cart with the new user + cart.user = user + cart.session_key = "" # Empty string instead of None + cart.save() + + # Send welcome email with password reset link + send_welcome_email(user) + + # Log in the new user + login(request, user, backend="django.contrib.auth.backends.ModelBackend") + else: + user = request.user + + # Lists to track enrollments for the receipt + enrollments = [] + session_enrollments = [] + goods_items = [] + total_amount = 0 + + # Define shipping_address + shipping_address = request.POST.get("address") if cart.has_goods else None + + # Check if the cart contains goods requiring shipping + has_goods = any(item.goods for item in cart.items.all()) + + # Extract shipping address from Stripe PaymentIntent + shipping_address = None + if has_goods: + shipping_data = getattr(payment_intent, "shipping", None) + if shipping_data: + # Construct structured shipping address + shipping_address = { + "line1": shipping_data.address.line1, + "line2": shipping_data.address.line2 or "", + "city": shipping_data.address.city, + "state": shipping_data.address.state, + "postal_code": shipping_data.address.postal_code, + "country": shipping_data.address.country, + } + + # Create the Order with shipping address + order = Order.objects.create( + user=user, # User is defined earlier in guest/auth logic + total_price=0, # Updated later + status="completed", + shipping_address=shipping_address, + terms_accepted=True, + ) + + storefront = None + # Process enrollments + for item in cart.items.all(): + if item.course: + # Check for an active discount coupon for this course + discount = Discount.objects.filter( + user=user, course=item.course, used=False, valid_until__gte=timezone.now() + ).first() + if discount: + # Calculate the discounted price + discount_amount = (discount.discount_percentage / 100) * item.course.price + effective_price = item.course.price - discount_amount + # Mark the coupon as used + discount.used = True + discount.save() + else: + effective_price = item.course.price + + # Create enrollment for the course + enrollment = Enrollment.objects.create( + student=user, course=item.course, status="approved", payment_intent_id=payment_intent_id + ) + enrollments.append(enrollment) + total_amount += effective_price + + # Create payment record for teacher earnings calculation + Payment.objects.create( + enrollment=enrollment, + amount=effective_price, + currency="USD", + stripe_payment_intent_id=payment_intent_id, + status="completed", + ) + + # Optionally, you can send confirmation emails with discount details + send_enrollment_confirmation(enrollment) + notify_teacher_new_enrollment(enrollment) + + elif item.session: + # Process individual session enrollments (no discount logic here) + session_enrollment = SessionEnrollment.objects.create( + student=user, session=item.session, status="approved", payment_intent_id=payment_intent_id + ) + session_enrollments.append(session_enrollment) + total_amount += item.session.price + + elif item.goods: + goods_items.append(item) + total_amount += item.final_price + OrderItem.objects.create( + order=order, + goods=item.goods, + quantity=1, + price_at_purchase=item.goods.price, + discounted_price_at_purchase=item.goods.discount_price, + ) + if not storefront: + storefront = item.goods.storefront + + # Update order details + order.total_price = total_amount + if storefront: + order.storefront = goods_items[0].goods.storefront + order.save() + + # Clear the cart + cart.items.all().delete() + + if storefront: + order.storefront = storefront + order.save(update_fields=["storefront"]) + + # Render the receipt page + return render( + request, + "cart/receipt.html", + { + "payment_intent_id": payment_intent_id, + "order_date": timezone.now(), + "user": user, + "enrollments": enrollments, + "session_enrollments": session_enrollments, + "goods_items": goods_items, + "total": total_amount, + "order": order, + "shipping_address": shipping_address, + }, + ) + + except stripe.error.StripeError as e: + # send slack message + send_slack_message(f"Payment verification failed: {str(e)}") + messages.error(request, f"Payment verification failed: {str(e)}") + return redirect("cart_view") + except Exception as e: + # send slack message + send_slack_message(f"Failed to process checkout: {str(e)}") + messages.error(request, f"Failed to process checkout: {str(e)}") + return redirect("cart_view") + + +def send_welcome_email(user): + """Send welcome email to newly created users after guest checkout.""" + if not user.email: + raise ValueError("User must have an email address to send welcome email") + + reset_url = reverse("account_reset_password") + context = { + "user": user, + "reset_url": reset_url, + } + + html_message = render_to_string("emails/welcome_guest.html", context) + text_message = render_to_string("emails/welcome_guest.txt", context) + + send_mail( + subject="Welcome to Your New Learning Account", + message=text_message, + html_message=html_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + ) + + +@login_required +def edit_session(request, session_id): + """Edit an existing session.""" + # Get the session and verify that the current user is the course teacher + session = get_object_or_404(Session, id=session_id) + course = session.course + + # Check if user is the course teacher + if request.user != course.teacher: + messages.error(request, "Only the course teacher can edit sessions!") + return redirect("course_detail", slug=course.slug) + + if request.method == "POST": + form = SessionForm(request.POST, instance=session) + if form.is_valid(): + form.save() + messages.success(request, "Session updated successfully!") + return redirect("course_detail", slug=session.course.slug) + else: + form = SessionForm(instance=session) + + return render( + request, "courses/session_form.html", {"form": form, "session": session, "course": course, "is_edit": True} + ) + + +@login_required +def invite_student(request, course_id): + course = get_object_or_404(Course, id=course_id) + + # Check if user is the teacher of this course + if course.teacher != request.user: + messages.error(request, "You are not authorized to invite students to this course.") + return redirect("course_detail", slug=course.slug) + + if request.method == "POST": + form = InviteStudentForm(request.POST) + if form.is_valid(): + email = form.cleaned_data["email"] + message = form.cleaned_data.get("message", "") + + # Generate course URL + course_url = request.build_absolute_uri(reverse("course_detail", args=[course.slug])) + + # Send invitation email + context = { + "course": course, + "teacher": request.user, + "message": message, + "course_url": course_url, + } + html_message = render_to_string("emails/course_invitation.html", context) + text_message = f""" +You have been invited to join {course.title}! + +Message from {request.user.get_full_name() or request.user.username}: +{message} + +Course Price: ${course.price} + +Click here to view the course: {course_url} +""" + + try: + send_mail( + f"Invitation to join {course.title}", + text_message, + settings.DEFAULT_FROM_EMAIL, + [email], + html_message=html_message, + ) + messages.success(request, f"Invitation sent to {email}") + return redirect("course_detail", slug=course.slug) + except Exception: + messages.error(request, "Failed to send invitation email. Please try again.") + else: + form = InviteStudentForm() + + context = { + "course": course, + "form": form, + } + return render(request, "courses/invite.html", context) + + +def terms(request): + """Display the terms of service page.""" + return render(request, "terms.html") + + +@login_required +@teacher_required +def stripe_connect_onboarding(request): + """Start the Stripe Connect onboarding process for teachers.""" + if not request.user.profile.is_teacher: + messages.error(request, "Only teachers can set up payment accounts.") + return redirect("profile") + + try: + if not request.user.profile.stripe_account_id: + # Create a new Stripe Connect account + account = stripe.Account.create( + type="express", + country="US", + email=request.user.email, + capabilities={ + "card_payments": {"requested": True}, + "transfers": {"requested": True}, + }, + ) + + # Save the account ID to the user's profile + request.user.profile.stripe_account_id = account.id + request.user.profile.save() + + # Create an account link for onboarding + account_link = stripe.AccountLink.create( + account=request.user.profile.stripe_account_id, + refresh_url=request.build_absolute_uri(reverse("stripe_connect_onboarding")), + return_url=request.build_absolute_uri(reverse("profile")), + type="account_onboarding", + ) + + return redirect(account_link.url) + + except stripe.error.StripeError as e: + messages.error(request, f"Failed to set up Stripe account: {str(e)}") + return redirect("profile") + + +@csrf_exempt +def stripe_connect_webhook(request): + """Handle Stripe Connect account updates.""" + payload = request.body + sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") + + try: + event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_CONNECT_WEBHOOK_SECRET) + except ValueError: + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError: + return HttpResponse(status=400) + + if event.type == "account.updated": + account = event.data.object + try: + profile = Profile.objects.get(stripe_account_id=account.id) + if account.charges_enabled and account.payouts_enabled: + profile.stripe_account_status = "verified" + else: + profile.stripe_account_status = "pending" + profile.save() + except Profile.DoesNotExist: + return HttpResponse(status=404) + + return HttpResponse(status=200) + + +@login_required +def create_forum_category(request): + """Create a new forum category.""" + if request.method == "POST": + form = ForumCategoryForm(request.POST) + if form.is_valid(): + category = form.save(commit=False) + if not category.slug: + category.slug = slugify(category.name) + category.save() + messages.success(request, f"Forum category '{category.name}' created successfully!") + return redirect("forum_category", slug=category.slug) + else: + print(form.errors) + else: + form = ForumCategoryForm() + + return render(request, "web/forum/create_category.html", {"form": form}) + + +@login_required +def edit_topic(request, topic_id): + topic = get_object_or_404(ForumTopic, id=topic_id, author=request.user) + categories = ForumCategory.objects.all() + + if request.method == "POST": + form = ForumTopicForm(request.POST) + if form.is_valid(): + # Manually update the topic instance with form data. + topic.title = form.cleaned_data["title"] + topic.content = form.cleaned_data["content"] + topic.github_issue_url = form.cleaned_data.get("github_issue_url", "") + topic.github_milestone_url = form.cleaned_data.get("github_milestone_url", "") + topic.save() + messages.success(request, "Topic updated successfully!") + return redirect("forum_topic", category_slug=topic.category.slug, topic_id=topic.id) + else: + # Prepopulate the form with the topic's current data. + initial_data = { + "title": topic.title, + "content": topic.content, + "github_issue_url": topic.github_issue_url, + "github_milestone_url": topic.github_milestone_url, + } + form = ForumTopicForm(initial=initial_data) + + return render(request, "web/forum/edit_topic.html", {"topic": topic, "form": form, "categories": categories}) + + +@login_required +def my_forum_topics(request): + """Display all forum topics created by the current user.""" + topics = ForumTopic.objects.filter(author=request.user).order_by("-created_at") + categories = ForumCategory.objects.all() + return render(request, "web/forum/my_topics.html", {"topics": topics, "categories": categories}) + + +@login_required +def my_forum_replies(request): + """Display all forum replies created by the current user.""" + replies = ( + ForumReply.objects.filter(author=request.user) + .select_related("topic", "topic__category") + .order_by("-created_at") + ) + categories = ForumCategory.objects.all() + return render(request, "web/forum/my_replies.html", {"replies": replies, "categories": categories}) + + +@login_required +def edit_reply(request, reply_id): + """Edit a forum reply.""" + reply = get_object_or_404(ForumReply, id=reply_id, author=request.user) + topic = reply.topic + categories = ForumCategory.objects.all() + + if request.method == "POST": + content = request.POST.get("content") + if content: + reply.content = content + reply.save() + messages.success(request, "Reply updated successfully.") + return redirect("forum_topic", category_slug=topic.category.slug, topic_id=topic.id) + + return render(request, "web/forum/edit_reply.html", {"reply": reply, "categories": categories}) + + +def get_course_calendar(request, slug): + """AJAX endpoint to get calendar data for a course.""" + course = get_object_or_404(Course, slug=slug) + today = timezone.now().date() + calendar_weeks = [] + + # Get current month and year from query parameters + year = int(request.GET.get("year", today.year)) + month = int(request.GET.get("month", today.month)) + current_month = timezone.datetime(year, month, 1).date() + + # Get previous and next month for navigation + if month == 1: + prev_month = {"year": year - 1, "month": 12} + else: + prev_month = {"year": year, "month": month - 1} + + if month == 12: + next_month = {"year": year + 1, "month": 1} + else: + next_month = {"year": year, "month": month + 1} + + # Get sessions for the current month + month_sessions = course.sessions.filter(start_time__year=year, start_time__month=month).order_by("start_time") + + # Generate calendar data + cal = calendar.monthcalendar(year, month) + + for week in cal: + calendar_week = [] + for day in week: + if day == 0: + calendar_week.append({"date": None, "has_session": False, "is_today": False}) + else: + date = timezone.datetime(year, month, day).date() + sessions_on_day = [s for s in month_sessions if s.start_time.date() == date] + calendar_week.append( + { + "date": date.isoformat() if date else None, + "has_session": bool(sessions_on_day), + "is_today": date == today, + } + ) + calendar_weeks.append(calendar_week) + + data = { + "calendar_weeks": calendar_weeks, + "current_month": current_month.strftime("%B %Y"), + "prev_month": prev_month, + "next_month": next_month, + } + + return JsonResponse(data) + + +@login_required +def create_calendar(request): + if request.method == "POST": + title = request.POST.get("title") + description = request.POST.get("description") + try: + month = int(request.POST.get("month")) + year = int(request.POST.get("year")) + + # Validate month is between 0-11 + if not 0 <= month <= 11: + return JsonResponse({"success": False, "error": "Month must be between 0 and 11"}, status=400) + + calendar = EventCalendar.objects.create( + title=title, description=description, creator=request.user, month=month, year=year + ) + + return JsonResponse({"success": True, "calendar_id": calendar.id, "share_token": calendar.share_token}) + except (ValueError, TypeError): + return JsonResponse({"success": False, "error": "Invalid month or year"}, status=400) + + return render(request, "calendar/create.html") + + +def view_calendar(request, share_token): + calendar = get_object_or_404(EventCalendar, share_token=share_token) + return render(request, "calendar/view.html", {"calendar": calendar}) + + +@require_POST +def add_time_slot(request, share_token): + try: + with transaction.atomic(): + calendar = get_object_or_404(EventCalendar, share_token=share_token) + name = request.POST.get("name") + day = int(request.POST.get("day")) + start_time = request.POST.get("start_time") + end_time = request.POST.get("end_time") + + # Create the time slot + TimeSlot.objects.create(calendar=calendar, name=name, day=day, start_time=start_time, end_time=end_time) + + return JsonResponse({"success": True}) + except IntegrityError: + return JsonResponse({"success": False, "error": "You already have a time slot for this day"}, status=400) + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}, status=400) + + +@require_POST +def remove_time_slot(request, share_token): + calendar = get_object_or_404(EventCalendar, share_token=share_token) + name = request.POST.get("name") + day = int(request.POST.get("day")) + + TimeSlot.objects.filter(calendar=calendar, name=name, day=day).delete() + + return JsonResponse({"success": True}) + + +@require_GET +def get_calendar_data(request, share_token): + calendar = get_object_or_404(EventCalendar, share_token=share_token) + slots = TimeSlot.objects.filter(calendar=calendar) + + data = { + "title": calendar.title, + "description": calendar.description, + "month": calendar.month, + "year": calendar.year, + "slots": [ + { + "name": slot.name, + "day": slot.day, + "start_time": slot.start_time.strftime("%H:%M"), + "end_time": slot.end_time.strftime("%H:%M"), + } + for slot in slots + ], + } + + return JsonResponse(data) + + +def system_status(request): + """Check system status including SendGrid API connectivity and disk space usage.""" + status = { + "sendgrid": {"status": "unknown", "message": "", "api_key_configured": False}, + "disk_space": {"status": "unknown", "message": "", "usage": {}}, + "timestamp": timezone.now(), + } + + # Check SendGrid + sendgrid_api_key = os.getenv("SENDGRID_PASSWORD") + if sendgrid_api_key: + status["sendgrid"]["api_key_configured"] = True + try: + print("Checking SendGrid API...") + response = requests.get( + "https://api.sendgrid.com/v3/user/account", + headers={"Authorization": f"Bearer {sendgrid_api_key}"}, + timeout=5, + ) + if response.status_code == 200: + status["sendgrid"]["status"] = "ok" + status["sendgrid"]["message"] = "Successfully connected to SendGrid API" + else: + status["sendgrid"]["status"] = "error" + status["sendgrid"]["message"] = f"Unexpected response: {response.status_code}" + except requests.exceptions.RequestException as e: + status["sendgrid"]["status"] = "error" + status["sendgrid"]["message"] = f"API Error: {str(e)}" + else: + status["sendgrid"]["status"] = "error" + status["sendgrid"]["message"] = "SendGrid API key not configured" + + # Check disk space + try: + total, used, free = shutil.disk_usage("/") + total_gb = total / (2**30) # Convert to GB + used_gb = used / (2**30) + free_gb = free / (2**30) + usage_percent = (used / total) * 100 + + status["disk_space"]["usage"] = { + "total_gb": round(total_gb, 2), + "used_gb": round(used_gb, 2), + "free_gb": round(free_gb, 2), + "percent": round(usage_percent, 1), + } + + # Set status based on usage percentage + if usage_percent >= 90: + status["disk_space"]["status"] = "error" + status["disk_space"]["message"] = "Critical: Disk usage above 90%" + elif usage_percent >= 80: + status["disk_space"]["status"] = "warning" + status["disk_space"]["message"] = "Warning: Disk usage above 80%" + else: + status["disk_space"]["status"] = "ok" + status["disk_space"]["message"] = "Disk space usage is normal" + except Exception as e: + status["disk_space"]["status"] = "error" + status["disk_space"]["message"] = f"Error checking disk space: {str(e)}" + + return render(request, "status.html", {"status": status}) + + +@login_required +@teacher_required +def message_enrolled_students(request, slug): + """Send an email to all enrolled students in a course with encrypted content.""" + course = get_object_or_404(Course, slug=slug, teacher=request.user) + + if request.method == "POST": + title = request.POST.get("title") + message = request.POST.get("message") + + if title and message: + original_message = message + + # Get all enrolled students + enrolled_students = User.objects.filter( + enrollments__course=course, enrollments__status="approved" + ).distinct() + + # Send email to each student with the encrypted message + for student in enrolled_students: + send_mail( + subject=f"[{course.title}] {title}", + message=original_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[student.email], + fail_silently=True, + ) + + messages.success(request, "Email sent successfully to all enrolled students!") + return redirect("course_detail", slug=slug) + else: + messages.error(request, "Both title and message are required!") + + return render(request, "courses/message_students.html", {"course": course}) + + +def message_teacher(request, teacher_id): + """Send a message to a teacher with secure encryption.""" + teacher = get_object_or_404(get_user_model(), id=teacher_id) + if not teacher.profile.is_teacher: + messages.error(request, "This user is not a teacher.") + return redirect("index") + + if request.method == "POST": + form = MessageTeacherForm(request.POST, user=request.user) + if form.is_valid(): + original_message = request.POST.get("message") + + # Prepare email content + if request.user.is_authenticated: + sender_name = request.user.get_full_name() or request.user.username + sender_email = request.user.email + else: + sender_name = form.cleaned_data["name"] + sender_email = form.cleaned_data["email"] + + # Send email to teacher using the encrypted message + context = { + "sender_name": sender_name, + "sender_email": sender_email, + "message": original_message, + "inbox_url": request.build_absolute_uri(reverse("inbox")), + "messaging_dashboard_url": request.build_absolute_uri(reverse("messaging_dashboard")), + } + html_message = render_to_string("web/emails/teacher_message.html", context) + + try: + send_mail( + subject=f"New message from {sender_name}", + message=original_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[teacher.email], + html_message=html_message, + ) + messages.success(request, "Your message has been sent successfully!") + + # Optionally redirect based on next URL parameter + next_url = request.GET.get("next") + if next_url: + try: + return redirect("course_detail", slug=next_url) + except NoReverseMatch: + pass + return redirect("course_search") + except Exception as e: + messages.error(request, f"Failed to send message: {str(e)}") + return redirect("message_teacher", teacher_id=teacher_id) + else: + form = MessageTeacherForm(user=request.user) + + return render( + request, + "web/message_teacher.html", + { + "form": form, + "teacher": teacher, + }, + ) + + +@login_required +def confirm_rolled_sessions(request, course_slug): + """View for teachers to confirm rolled over session dates.""" + course = get_object_or_404(Course, slug=course_slug, teacher=request.user) + + # Get all rolled over but unconfirmed sessions + rolled_sessions = course.sessions.filter(is_rolled_over=True, teacher_confirmed=False).order_by("start_time") + + if request.method == "POST": + session_ids = request.POST.getlist("confirm_sessions") + if session_ids: + # Confirm selected sessions + course.sessions.filter(id__in=session_ids).update(teacher_confirmed=True) + messages.success(request, "Selected sessions have been confirmed.") + + # Reset rollover status for unselected sessions + unselected_sessions = rolled_sessions.exclude(id__in=session_ids) + for session in unselected_sessions: + session.start_time = session.original_start_time + session.end_time = session.original_end_time + session.is_rolled_over = False + session.save() + + messages.info(request, "Unselected sessions have been reset to their original dates.") + + return redirect("course_detail", slug=course_slug) + + return render( + request, + "courses/confirm_rolled_sessions.html", + { + "course": course, + "rolled_sessions": rolled_sessions, + }, + ) + + +def feedback(request): + if request.method == "POST": + form = FeedbackForm(request.POST) + if form.is_valid(): + # Send feedback notification to admin + name = form.cleaned_data.get("name", "Anonymous") + email = form.cleaned_data.get("email", "Not provided") + description = form.cleaned_data["description"] + + # Send to Slack if webhook URL is configured + if settings.SLACK_WEBHOOK_URL: + message = f"*New Feedback*\nFrom: {name}\nEmail: {email}\n\n{description}" + send_slack_message(message) + + messages.success(request, "Thank you for your feedback! We appreciate your input.") + return redirect("feedback") + else: + form = FeedbackForm() + + return render(request, "feedback.html", {"form": form}) + + +def content_dashboard(request): + # Get current time and thresholds + now = timezone.now() + month_ago = now - timedelta(days=30) + + def get_status(date, threshold_days=None): + if not date: + return "neutral" + if not threshold_days: + return "success" + threshold = now - timedelta(days=threshold_days) + if date >= threshold: + return "success" + elif date >= (threshold - timedelta(days=threshold_days)): + return "warning" + return "danger" + + # Web traffic stats + web_stats = { + "total_views": WebRequest.objects.aggregate(total=Sum("count"))["total"] or 0, + "unique_visitors": WebRequest.objects.values("ip_address").distinct().count(), + "date": WebRequest.objects.order_by("-created").first().created if WebRequest.objects.exists() else None, + } + web_stats["status"] = get_status(web_stats["date"]) + + # Generate traffic data for chart (last 30 days) + traffic_data = [] + for i in range(30): + date = now - timedelta(days=i) + day_views = WebRequest.objects.filter(created__date=date.date()).aggregate(total=Sum("count"))["total"] or 0 + traffic_data.append({"date": date.strftime("%Y-%m-%d"), "views": day_views}) + traffic_data.reverse() # Most recent last for chart + + # Blog stats + blog_stats = { + "posts": BlogPost.objects.filter(status="published").count(), + "views": (WebRequest.objects.filter(path__startswith="/blog/").aggregate(total=Sum("count"))["total"] or 0), + "date": ( + BlogPost.objects.filter(status="published").order_by("-published_at").first().published_at + if BlogPost.objects.exists() + else None + ), + } + blog_stats["status"] = get_status(blog_stats["date"], 7) + + # Forum stats + forum_stats = { + "topics": ForumTopic.objects.count(), + "replies": ForumReply.objects.count(), + "date": ForumTopic.objects.order_by("-created_at").first().created_at if ForumTopic.objects.exists() else None, + } + forum_stats["status"] = get_status(forum_stats["date"], 1) # 1 day threshold + + # Course stats + course_stats = { + "active": Course.objects.filter(status="published").count(), + "students": Enrollment.objects.filter(status="approved").count(), + "date": Course.objects.order_by("-created_at").first().created_at if Course.objects.exists() else None, + } + course_stats["status"] = get_status(course_stats["date"], 30) # 1 month threshold + + # User stats + user_stats = { + "total": User.objects.count(), + "active": User.objects.filter(last_login__gte=month_ago).count(), + "date": User.objects.order_by("-date_joined").first().date_joined if User.objects.exists() else None, + } + + def get_status(date, threshold_days): + if not date: + return "danger" + days_since = (now - date).days + if days_since > threshold_days * 2: + return "danger" + elif days_since > threshold_days: + return "warning" + return "success" + + # Calculate overall health score + connected_platforms = 0 + healthy_platforms = 0 + platforms_data = [ + (blog_stats["date"], 7), # Blog: 1 week threshold + (forum_stats["date"], 7), # Forum: 1 week threshold + (course_stats["date"], 7), # Courses: 1 week threshold + (user_stats["date"], 7), # Users: 1 week threshold + ] + + for date, threshold in platforms_data: + if date: + connected_platforms += 1 + if get_status(date, threshold) != "danger": + healthy_platforms += 1 + + overall_score = int((healthy_platforms / max(connected_platforms, 1)) * 100) + + # Get social media stats + social_stats = get_social_stats() + content_data = { + "blog": { + "stats": blog_stats, + "status": get_status(blog_stats["date"], 7), + "date": blog_stats["date"], + }, + "forum": { + "stats": forum_stats, + "status": get_status(forum_stats["date"], 7), + "date": forum_stats["date"], + }, + "courses": { + "stats": course_stats, + "status": get_status(course_stats["date"], 7), + "date": course_stats["date"], + }, + "users": { + "stats": user_stats, + "status": get_status(user_stats["date"], 7), + "date": user_stats["date"], + }, + } + + # Add social media stats + content_data.update(social_stats) + + return render( + request, + "web/dashboard/content_status.html", + { + "content_data": content_data, + "overall_score": overall_score, + "web_stats": web_stats, + "traffic_data": json.dumps(traffic_data), + "blog_stats": blog_stats, + "forum_stats": forum_stats, + "course_stats": course_stats, + "user_stats": user_stats, + }, + ) + + +# Challenges views +def current_weekly_challenge(request): + current_time = timezone.now() + weekly_challenge = Challenge.objects.filter( + challenge_type="weekly", start_date__lte=current_time, end_date__gte=current_time + ).first() + + one_time_challenges = Challenge.objects.filter( + challenge_type="one_time", start_date__lte=current_time, end_date__gte=current_time + ) + user_submissions = {} + if request.user.is_authenticated: + # Get all active challenges + all_challenges = [] + if weekly_challenge: + all_challenges.append(weekly_challenge) + all_challenges.extend(list(one_time_challenges)) + + # Get all submissions for active challenges + if all_challenges: + submissions = ChallengeSubmission.objects.filter(user=request.user, challenge__in=all_challenges) + # Create a dictionary mapping challenge IDs to submissions + for submission in submissions: + user_submissions[submission.challenge_id] = submission + + return render( + request, + "web/current_weekly_challenge.html", + { + "current_challenge": weekly_challenge, + "one_time_challenges": one_time_challenges, + "user_submissions": user_submissions if request.user.is_authenticated else {}, + }, + ) + + +def challenge_detail(request, challenge_id): + try: + challenge = get_object_or_404(Challenge, id=challenge_id) + submissions = ChallengeSubmission.objects.filter(challenge=challenge) + # Check if the current user has submitted this challenge + user_submission = None + if request.user.is_authenticated: + user_submission = ChallengeSubmission.objects.filter(user=request.user, challenge=challenge).first() + + return render( + request, + "web/challenge_detail.html", + {"challenge": challenge, "submissions": submissions, "user_submission": user_submission}, + ) + except Http404: + # Redirect to weekly challenges list if specific challenge not found + messages.info(request, "Challenge not found. Returning to challenges list.") + return redirect("current_weekly_challenge") + + +@login_required +def challenge_submit(request, challenge_id): + challenge = get_object_or_404(Challenge, id=challenge_id) + # Check if the user has already submitted this challenge + existing_submission = ChallengeSubmission.objects.filter(user=request.user, challenge=challenge).first() + + if existing_submission: + return redirect("challenge_detail", challenge_id=challenge_id) + + if request.method == "POST": + form = ChallengeSubmissionForm(request.POST) + if form.is_valid(): + submission = form.save(commit=False) + submission.user = request.user + submission.challenge = challenge + submission.save() + messages.success(request, "Your submission has been recorded!") + return redirect("challenge_detail", challenge_id=challenge_id) + else: + form = ChallengeSubmissionForm() + + return render(request, "web/challenge_submit.html", {"challenge": challenge, "form": form}) + + +@require_GET +def fetch_video_title(request): + """ + Fetch video title from a URL with proper security measures to prevent SSRF attacks. + """ + url = request.GET.get("url") + if not url: + return JsonResponse({"error": "URL parameter is required"}, status=400) + + # Validate URL + try: + parsed_url = urlparse(url) + + # Check for scheme - only allow http and https + if parsed_url.scheme not in ["http", "https"]: + return JsonResponse({"error": "Invalid URL scheme. Only HTTP and HTTPS are supported."}, status=400) + + # Check for private/internal IP addresses + if parsed_url.netloc: + hostname = parsed_url.netloc.split(":")[0] + + # Block localhost variations and common internal domains + blocked_hosts = [ + "localhost", + "127.0.0.1", + "0.0.0.0", + "internal", + "intranet", + "local", + "lan", + "corp", + "private", + "::1", + ] + + if any(blocked in hostname.lower() for blocked in blocked_hosts): + return JsonResponse({"error": "Access to internal networks is not allowed"}, status=403) + + # Resolve hostname to IP and check if it's private + try: + ip_address = socket.gethostbyname(hostname) + ip_obj = ipaddress.ip_address(ip_address) + + # Check if the IP is private/internal + if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_multicast: + return JsonResponse({"error": "Access to internal/private networks is not allowed"}, status=403) + except (socket.gaierror, ValueError): + # If hostname resolution fails or IP parsing fails, continue + pass + + except Exception as e: + return JsonResponse({"error": f"Invalid URL format: {str(e)}"}, status=400) + + # Set a timeout to prevent hanging requests + timeout = 5 # seconds + + try: + # Only allow HEAD and GET methods with limited redirects + response = requests.get( + url, + timeout=timeout, + allow_redirects=True, + headers={ + "User-Agent": "Educational-Website-Validator/1.0", + }, + ) + response.raise_for_status() + + # Extract title from response headers or content + title = response.headers.get("title", "") + if not title: + # Try to extract title from HTML content + content = response.text + title_match = re.search(r"(.*?)", content, re.IGNORECASE | re.DOTALL) + title = title_match.group(1).strip() if title_match else "Untitled Video" + + # Sanitize the title + title = html.escape(title) + + return JsonResponse({"title": title}) + + except requests.RequestException as e: + logger.error(f"Error fetching video title from {url}: {str(e)}") + return JsonResponse({"error": f"Failed to fetch video title: {str(e)}"}, status=500) + except Exception as e: + logger.error(f"Unexpected error fetching video title from {url}: {str(e)}") + return JsonResponse({"error": "An unexpected error occurred while fetching the title"}, status=500) + + +def get_referral_stats(): + """Get statistics for top referrers.""" + return ( + Profile.objects.annotate( + total_signups=Count("referrals"), + total_enrollments=Count( + "referrals__user__enrollments", filter=Q(referrals__user__enrollments__status="approved") + ), + total_clicks=Count( + "referrals__user__webrequest", filter=Q(referrals__user__webrequest__path__contains="ref=") + ), + ) + .filter(total_signups__gt=0) + .order_by("-total_signups")[:10] + ) + + +def referral_leaderboard(request): + """Display the referral leaderboard.""" + top_referrers = get_referral_stats() + return render(request, "web/referral_leaderboard.html", {"top_referrers": top_referrers}) + + +# Goods Views +class GoodsListView(LoginRequiredMixin, generic.ListView): + model = Goods + template_name = "goods/goods_list.html" + context_object_name = "products" + + def get_queryset(self): + return Goods.objects.filter(storefront__teacher=self.request.user) + + +class GoodsDetailView(generic.DetailView): + model = Goods + template_name = "goods/goods_detail.html" + context_object_name = "product" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["product_images"] = self.object.goods_images.all() # Get all images related to the product + context["other_products"] = Goods.objects.exclude(pk=self.object.pk)[:12] # Fetch other products + view_data = WebRequest.objects.filter(path=self.request.path).aggregate(total_views=Coalesce(Sum("count"), 0)) + context["view_count"] = view_data["total_views"] + + # Add cart count for each product + products_with_cart_count = [] + for product in context["other_products"]: + product.cart_count = product.cart_items.count() + products_with_cart_count.append(product) + + context["other_products"] = products_with_cart_count + + return context + + +class GoodsCreateView(LoginRequiredMixin, UserPassesTestMixin, generic.CreateView): + model = Goods + form_class = GoodsForm + template_name = "goods/goods_form.html" + + def test_func(self): + return hasattr(self.request.user, "storefront") + + def form_valid(self, form): + form.instance.storefront = self.request.user.storefront + images = self.request.FILES.getlist("images") + product_type = form.cleaned_data.get("product_type") + + # Validate digital product images + if product_type == "digital" and not images: + form.add_error(None, "Digital products require at least one image") + return self.form_invalid(form) + + # Validate image constraints + if len(images) > 8: + form.add_error(None, "Maximum 8 images allowed") + return self.form_invalid(form) + + for img in images: + if img.size > 5 * 1024 * 1024: + form.add_error(None, f"{img.name} exceeds 5MB size limit") + return self.form_invalid(form) + + # Save main product first + super().form_valid(form) + + # Save images after product creation + for image_file in images: + ProductImage.objects.create(goods=self.object, image=image_file) + + return render(self.request, "goods/goods_create_success.html", {"product": self.object}) + + def form_invalid(self, form): + messages.error(self.request, f"Creation failed: {form.errors.as_text()}") + return super().form_invalid(form) + + def get_success_url(self): + return reverse("goods_list") + + +class GoodsUpdateView(LoginRequiredMixin, UserPassesTestMixin, generic.UpdateView): + model = Goods + form_class = GoodsForm + template_name = "goods/goods_update.html" + + # Filter by user's products only + def get_queryset(self): + return Goods.objects.filter(storefront__teacher=self.request.user) + + # Verify ownership + def test_func(self): + return self.get_object().storefront.teacher == self.request.user + + def get_success_url(self): + return reverse("goods_list") + + +class GoodsDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): + model = Goods + template_name = "goods/goods_confirm_delete.html" + success_url = reverse_lazy("goods_list") + + def test_func(self): + return self.request.user == self.get_object().storefront.teacher + + +def add_goods_to_cart(request, pk): + """Add a product (goods) to the cart.""" + product = get_object_or_404(Goods, pk=pk) + + # Prevent adding out-of-stock items + if product.stock is None or product.stock <= 0: + messages.error(request, f"{product.name} is out of stock and cannot be added to cart.") + return redirect("goods_detail", pk=pk) # Redirect back to product page + + cart = get_or_create_cart(request) + cart_item, created = CartItem.objects.get_or_create(cart=cart, goods=product, defaults={"session": None}) + + if created: + messages.success(request, f"{product.name} added to cart.") + else: + messages.info(request, f"{product.name} is already in your cart.") + + return redirect("cart_view") + + +class GoodsListingView(ListView): + model = Goods + template_name = "goods/goods_listing.html" + context_object_name = "products" + paginate_by = 15 + + def get_queryset(self): + queryset = Goods.objects.all() + store_name = self.request.GET.get("store_name") + product_type = self.request.GET.get("product_type") + category = self.request.GET.get("category") + min_price = self.request.GET.get("min_price") + max_price = self.request.GET.get("max_price") + + if store_name: + queryset = queryset.filter(storefront__name__icontains=store_name) + if product_type: + queryset = queryset.filter(product_type=product_type) + if category: + queryset = queryset.filter(category__icontains=category) + if min_price: + queryset = queryset.filter(price__gte=min_price) + if max_price: + queryset = queryset.filter(price__lte=max_price) + + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["store_names"] = Storefront.objects.values_list("name", flat=True).distinct() + context["categories"] = Goods.objects.values_list("category", flat=True).distinct() + + # Add cart count for each product + products_with_cart_count = [] + for product in context["products"]: + product.cart_count = product.cart_items.count() + products_with_cart_count.append(product) + + context["products"] = products_with_cart_count + + return context + + +# Order Management +class OrderManagementView(LoginRequiredMixin, UserPassesTestMixin, generic.ListView): + model = Order + template_name = "orders/order_management.html" + context_object_name = "orders" + paginate_by = 20 + + def test_func(self): + storefront = get_object_or_404(Storefront, store_slug=self.kwargs["store_slug"]) + return self.request.user == storefront.teacher + + def get_queryset(self): + queryset = Order.objects.filter(items__goods__storefront__store_slug=self.kwargs["store_slug"]).distinct() + + # Get status from request and filter + selected_status = self.request.GET.get("status") + if selected_status and selected_status != "all": + queryset = queryset.filter(status=selected_status) + + return queryset + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["statuses"] = Order.STATUS_CHOICES # Directly from model + context["selected_status"] = self.request.GET.get("status", "") + return context + + +class OrderDetailView(LoginRequiredMixin, generic.DetailView): + model = Order + template_name = "orders/order_detail.html" + context_object_name = "order" + + +@login_required +@require_POST +def update_order_status(request, item_id): + order = get_object_or_404(Order, id=item_id, user=request.user) + new_status = request.POST.get("status").lower() # Convert to lowercase for consistency + + # Define allowed statuses inside the function + VALID_STATUSES = ["draft", "pending", "processing", "shipped", "completed", "cancelled", "refunded"] + + if new_status not in VALID_STATUSES: + messages.error(request, "Invalid status.") + return redirect("order_detail", pk=item_id) + + order.status = new_status + order.save() + messages.success(request, "Order status updated successfully.") + return redirect("order_detail", pk=item_id) + + +# Analytics +class StoreAnalyticsView(LoginRequiredMixin, UserPassesTestMixin, generic.TemplateView): + template_name = "analytics/analytics_dashboard.html" + + def test_func(self): + storefront = get_object_or_404(Storefront, store_slug=self.kwargs["store_slug"]) + return self.request.user == storefront.teacher + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + storefront = get_object_or_404(Storefront, store_slug=self.kwargs["store_slug"]) + + # Store-specific analytics + orders = Order.objects.filter(storefront=storefront, status="completed") + + context.update( + { + "total_sales": orders.count(), + "total_revenue": orders.aggregate(Sum("total_price"))["total_price__sum"] or 0, + "top_products": OrderItem.objects.filter(order__storefront=storefront) + .values("goods__name") + .annotate(total_sold=Sum("quantity")) + .order_by("-total_sold")[:5], + "storefront": storefront, + } + ) + return context + + +class AdminMerchAnalyticsView(LoginRequiredMixin, UserPassesTestMixin, generic.TemplateView): + template_name = "analytics/admin_analytics.html" + + def test_func(self): + return self.request.user.is_staff + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Platform-wide analytics + context.update( + { + "total_sales": Order.objects.filter(status="completed").count(), + "total_revenue": Order.objects.filter(status="completed").aggregate(Sum("total_price"))[ + "total_price__sum" + ] + or 0, + "top_storefronts": Storefront.objects.annotate(total_sales=Count("goods__orderitem")).order_by( + "-total_sales" + )[:5], + } + ) + return context + + +@login_required +def sales_analytics(request): + """View for displaying sales analytics.""" + storefront = get_object_or_404(Storefront, teacher=request.user) + + # Get completed orders for this storefront + orders = Order.objects.filter(storefront=storefront, status="completed") + + # Calculate metrics + total_revenue = orders.aggregate(total=Sum("total_price"))["total"] or 0 + total_orders = orders.count() + + # Placeholder conversion rate (to be implemented properly later) + conversion_rate = 0.00 # Temporary placeholder + + # Best selling products + best_selling_products = ( + OrderItem.objects.filter(order__storefront=storefront) + .values("goods__name") + .annotate(total_sold=Sum("quantity")) + .order_by("-total_sold")[:5] + ) + + context = { + "total_revenue": total_revenue, + "total_orders": total_orders, + "conversion_rate": conversion_rate, + "best_selling_products": best_selling_products, + } + return render(request, "analytics/analytics_dashboard.html", context) + + +@login_required +def sales_data(request): + # Get the user's storefront + storefront = get_object_or_404(Storefront, teacher=request.user) + + # Define valid statuses for metrics (e.g., include "completed" and "shipped") + valid_statuses = ["completed", "shipped"] + orders = Order.objects.filter(storefront=storefront, status__in=valid_statuses) + + # Calculate total revenue + total_revenue = orders.aggregate(total=Sum("total_price"))["total"] or 0 + + # Calculate total orders + total_orders = orders.count() + + # Calculate conversion rate (orders / visits * 100) + total_visits = WebRequest.objects.filter(path__contains="ref=").count() # Adjust based on visit tracking + conversion_rate = (total_orders / total_visits * 100) if total_visits > 0 else 0.00 + + # Get best-selling products + best_selling_products = ( + OrderItem.objects.filter(order__storefront=storefront, order__status__in=valid_statuses) + .values("goods__name") + .annotate(total_sold=Sum("quantity")) + .order_by("-total_sold")[:5] + ) + + # Prepare response data + data = { + "total_revenue": float(total_revenue), + "total_orders": total_orders, + "conversion_rate": round(conversion_rate, 2), + "best_selling_products": list(best_selling_products), + } + return JsonResponse(data) + + +class StorefrontCreateView(LoginRequiredMixin, CreateView): + model = Storefront + form_class = StorefrontForm + template_name = "storefront/storefront_form.html" + success_url = "/dashboard/teacher/" + + def dispatch(self, request, *args, **kwargs): + if Storefront.objects.filter(teacher=request.user).exists(): + return redirect("storefront_update", store_slug=request.user.storefront.store_slug) + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + form.instance.teacher = self.request.user # Set the teacher field to the current user + return super().form_valid(form) + + +class StorefrontUpdateView(LoginRequiredMixin, UpdateView): + model = Storefront + form_class = StorefrontForm + template_name = "storefront/storefront_form.html" + success_url = "/dashboard/teacher/" + + def get_object(self): + return get_object_or_404(Storefront, teacher=self.request.user) + + +class StorefrontDetailView(LoginRequiredMixin, generic.DetailView): + model = Storefront + template_name = "storefront/storefront_detail.html" + context_object_name = "storefront" + + def get_object(self): + return get_object_or_404(Storefront, store_slug=self.kwargs["store_slug"]) + + +def success_story_list(request): + """View for listing published success stories.""" + success_stories = SuccessStory.objects.filter(status="published").order_by("-published_at") + + # Paginate results + paginator = Paginator(success_stories, 9) # 9 stories per page + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + + context = { + "success_stories": page_obj, + "is_paginated": paginator.num_pages > 1, + "page_obj": page_obj, + } + return render(request, "success_stories/list.html", context) + + +def success_story_detail(request, slug): + """View for displaying a single success story.""" + success_story = get_object_or_404(SuccessStory, slug=slug, status="published") + + # Get related success stories (same author or similar content) + related_stories = ( + SuccessStory.objects.filter(status="published").exclude(id=success_story.id).order_by("-published_at")[:3] + ) + + context = { + "success_story": success_story, + "related_stories": related_stories, + } + return render(request, "success_stories/detail.html", context) + + +@login_required +def create_success_story(request): + """View for creating a new success story.""" + if request.method == "POST": + form = SuccessStoryForm(request.POST, request.FILES) + if form.is_valid(): + success_story = form.save(commit=False) + success_story.author = request.user + success_story.save() + messages.success(request, "Success story created successfully!") + return redirect("success_story_detail", slug=success_story.slug) + else: + form = SuccessStoryForm() + + context = { + "form": form, + } + return render(request, "success_stories/create.html", context) + + +@login_required +def edit_success_story(request, slug): + """View for editing an existing success story.""" + success_story = get_object_or_404(SuccessStory, slug=slug, author=request.user) + + if request.method == "POST": + form = SuccessStoryForm(request.POST, request.FILES, instance=success_story) + if form.is_valid(): + form.save() + messages.success(request, "Success story updated successfully!") + return redirect("success_story_detail", slug=success_story.slug) + else: + form = SuccessStoryForm(instance=success_story) + + context = { + "form": form, + "success_story": success_story, + "is_edit": True, + } + return render(request, "success_stories/create.html", context) + + +@login_required +def delete_success_story(request, slug): + """View for deleting a success story.""" + success_story = get_object_or_404(SuccessStory, slug=slug, author=request.user) + + if request.method == "POST": + success_story.delete() + messages.success(request, "Success story deleted successfully!") + return redirect("success_story_list") + + context = { + "success_story": success_story, + } + return render(request, "success_stories/delete_confirm.html", context) + + +def gsoc_landing_page(request): + """ + Renders the GSOC landing page with top GitHub contributors + based on merged pull requests + """ + import logging + + import requests + from django.conf import settings + + # Initialize an empty list for contributors in case the GitHub API call fails + top_contributors = [] + + # GitHub API URL for the education-website repository + github_repo_url = "https://api.github.com/repos/alphaonelabs/education-website" + + # Users to exclude from the contributor list (bots and automated users) + excluded_users = ["A1L13N", "dependabot[bot]"] + + try: + # Fetch contributors from GitHub API + headers = {} + # Check if GitHub token is configured + if hasattr(settings, "GITHUB_TOKEN") and settings.GITHUB_TOKEN: + headers["Authorization"] = f"token {settings.GITHUB_TOKEN}" + + # Get all closed pull requests - we'll filter for merged ones in code + # The GitHub API doesn't have a direct 'merged' filter in the query params + # so we get all closed PRs and then check the 'merged_at' field + pull_requests_response = requests.get( + f"{github_repo_url}/pulls", + params={ + "state": "closed", # closed PRs could be either merged or just closed + "sort": "updated", + "direction": "desc", + "per_page": 100, + }, + headers=headers, + timeout=5, + ) + + # Check for rate limiting + if pull_requests_response.status_code == 403 and "X-RateLimit-Remaining" in pull_requests_response.headers: + remaining = pull_requests_response.headers.get("X-RateLimit-Remaining") + if remaining == "0": + reset_time = int(pull_requests_response.headers.get("X-RateLimit-Reset", 0)) + reset_datetime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(reset_time)) + logging.warning(f"GitHub API rate limit exceeded. Resets at {reset_datetime}") + + if pull_requests_response.status_code == 200: + pull_requests = pull_requests_response.json() + + # Create a map of contributors with their PR count + contributor_stats = defaultdict( + lambda: {"merged_pr_count": 0, "avatar_url": "", "profile_url": "", "prs_url": ""} + ) + + # Process each pull request + for pr in pull_requests: + # Check if the PR was merged + if pr.get("merged_at"): + username = pr["user"]["login"] + + # Skip excluded users + if username in excluded_users: + continue + + contributor_stats[username]["merged_pr_count"] += 1 + contributor_stats[username]["avatar_url"] = pr["user"]["avatar_url"] + contributor_stats[username]["profile_url"] = pr["user"]["html_url"] + # Add a direct link to the user's PRs for this repository + base_url = "https://github.com/alphaonelabs/education-website/pulls" + query = f"?q=is:pr+author:{username}+is:merged" + contributor_stats[username]["prs_url"] = base_url + query + contributor_stats[username]["username"] = username + + # Convert to list and sort by PR count + top_contributors = [v for k, v in contributor_stats.items()] + top_contributors.sort(key=lambda x: x["merged_pr_count"], reverse=True) + + # Get top 10 contributors + top_contributors = top_contributors[:10] + + except Exception as e: + logging.error(f"Error fetching GitHub contributors: {str(e)}") + + context = {"top_contributors": top_contributors} + + return render(request, "gsoc_landing_page.html", context) + + +def whiteboard(request): + return render(request, "whiteboard.html") + + +def graphing_calculator(request): + return render(request, "graphing_calculator.html") + + +def meme_list(request): + memes = Meme.objects.all().order_by("-created_at") + subjects = Subject.objects.filter(memes__isnull=False).distinct() + # Filter by subject if provided + subject_filter = request.GET.get("subject") + if subject_filter: + memes = memes.filter(subject__slug=subject_filter) + paginator = Paginator(memes, 12) # Show 12 memes per page + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + + return render(request, "memes.html", {"memes": page_obj, "subjects": subjects, "selected_subject": subject_filter}) + + +def meme_detail(request: HttpRequest, slug: str) -> HttpResponse: + meme = get_object_or_404(Meme, slug=slug) + return render(request, "meme_detail.html", {"meme": meme}) + + +@login_required +def add_meme(request): + if request.method == "POST": + form = MemeForm(request.POST, request.FILES) + if form.is_valid(): + meme = form.save(commit=False) # The form handles subject creation logic internally + meme.uploader = request.user + meme.save() + messages.success(request, "Your meme has been uploaded successfully!") + return redirect("meme_list") + else: + form = MemeForm() + subjects = Subject.objects.all().order_by("name") + return render(request, "add_meme.html", {"form": form, "subjects": subjects}) + + +@login_required +def team_goals(request): + """List all team goals the user is part of or has created.""" + user_goals = ( + TeamGoal.objects.filter(Q(creator=request.user) | Q(members__user=request.user)) + .distinct() + .order_by("-created_at") + ) + + paginator = Paginator(user_goals, 10) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + pending_invites = TeamInvite.objects.filter(recipient=request.user, status="pending").select_related( + "goal", "sender" + ) + + context = { + "goals": page_obj, + "pending_invites": pending_invites, + "is_paginated": paginator.num_pages > 1, + } + return render(request, "teams/list.html", context) + + +@login_required +def create_team_goal(request): + """Create a new team goal.""" + if request.method == "POST": + form = TeamGoalForm(request.POST) + if form.is_valid(): + with transaction.atomic(): + goal = form.save(commit=False) + goal.creator = request.user + goal.save() + + # Add creator as a member + TeamGoalMember.objects.create(team_goal=goal, user=request.user, role="leader") + + messages.success(request, "Team goal created successfully!") + return redirect("team_goal_detail", goal_id=goal.id) + else: + form = TeamGoalForm() + + return render(request, "teams/create.html", {"form": form}) + + +@login_required +def team_goal_detail(request, goal_id): + """View and manage a specific team goal.""" + goal = get_object_or_404(TeamGoal.objects.prefetch_related("members__user"), id=goal_id) + + # Check if user has access to this goal + if not (goal.creator == request.user or goal.members.filter(user=request.user).exists()): + messages.error(request, "You do not have access to this team goal.") + return redirect("team_goals") + + # Get existing team members to exclude from invitation + existing_members = goal.members.values_list("user_id", flat=True) + + # Handle inviting new members + if request.method == "POST": + form = TeamInviteForm(request.POST) + if form.is_valid(): + # Check for existing invites using the validated User object + if TeamInvite.objects.filter( + goal__id=goal.id, recipient=form.cleaned_data["recipient"] # Changed to use User object + ).exists(): + messages.warning(request, "An invite for this user is already pending.") + return redirect("team_goal_detail", goal_id=goal.id) + invite = form.save(commit=False) + invite.sender = request.user + invite.goal = goal + invite.save() + messages.success(request, f"Invitation sent to {invite.recipient.email}!") + notify_team_invite(invite) + return redirect("team_goal_detail", goal_id=goal.id) + + else: + form = TeamInviteForm() + + # Get users that can be invited (exclude existing members and the creator) + available_users = User.objects.exclude(id__in=list(existing_members) + [goal.creator.id]).values( + "id", "username", "email" + ) + + context = { + "goal": goal, + "invite_form": form, + "user_is_leader": goal.members.filter(user=request.user, role="leader").exists(), + "available_users": available_users, + } + return render(request, "teams/detail.html", context) + + +@login_required +def accept_team_invite(request, invite_id): + """Accept a team invitation.""" + invite = get_object_or_404( + TeamInvite.objects.select_related("goal"), id=invite_id, recipient=request.user, status="pending" + ) + + # Create team member using get_or_create to avoid race conditions + member, created = TeamGoalMember.objects.get_or_create( + team_goal=invite.goal, user=request.user, defaults={"role": "member"} + ) + + if not created: + messages.info(request, f"You are already a member of {invite.goal.title}.") + else: + messages.success(request, f"You have joined {invite.goal.title}!") + + # Update invite status + invite.status = "accepted" + invite.responded_at = timezone.now() + invite.save() + + notify_team_invite_response(invite) + return redirect("team_goal_detail", goal_id=invite.goal.id) + + +@login_required +def decline_team_invite(request, invite_id): + """Decline a team invitation.""" + invite = get_object_or_404(TeamInvite, id=invite_id, recipient=request.user, status="pending") + + invite.status = "declined" + invite.responded_at = timezone.now() + invite.save() + + notify_team_invite_response(invite) + messages.info(request, f"You have declined to join {invite.goal.title}.") + return redirect("team_goals") + + +@login_required +def edit_team_goal(request, goal_id): + """Edit an existing team goal.""" + goal = get_object_or_404(TeamGoal, id=goal_id) + + # Check if user is the creator or a leader + if not (goal.creator == request.user or goal.members.filter(user=request.user, role="leader").exists()): + messages.error(request, "You don't have permission to edit this team goal.") + return redirect("team_goal_detail", goal_id=goal_id) + + if request.method == "POST": + form = TeamGoalForm(request.POST, instance=goal) + if form.is_valid(): + # Validate that deadline is not in the past + if form.cleaned_data["deadline"] < timezone.now(): + form.add_error("deadline", "Deadline cannot be in the past.") + context = { + "form": form, + "goal": goal, + "is_edit": True, + } + return render(request, "teams/create.html", context) + form.save() + messages.success(request, "Team goal updated successfully!") + return redirect("team_goal_detail", goal_id=goal.id) + else: + form = TeamGoalForm(instance=goal) + + context = { + "form": form, + "goal": goal, + "is_edit": True, + } + return render(request, "teams/create.html", context) + + +@login_required +def mark_team_contribution(request, goal_id): + """Allow a team member to mark their contribution as complete.""" + goal = get_object_or_404(TeamGoal, id=goal_id) + + # Find the current user's membership in this goal + member = goal.members.filter(user=request.user).first() + + if not member: + messages.error(request, "You are not a member of this team goal.") + return redirect("team_goal_detail", goal_id=goal_id) + + if member.completed: + messages.info(request, "Your contribution is already marked as complete.") + return redirect("team_goal_detail", goal_id=goal_id) + + # Mark the user's contribution as complete + member.mark_completed() + messages.success(request, "Your contribution has been marked as complete.") + notify_team_goal_completion(goal, request.user) + return redirect("team_goal_detail", goal_id=goal_id) + + +@login_required +def remove_team_member(request, goal_id, member_id): + """Remove a member from a team goal.""" + goal = get_object_or_404(TeamGoal, id=goal_id) + + # Check if user is the creator or a leader + if not (goal.creator == request.user or goal.members.filter(user=request.user, role="leader").exists()): + messages.error(request, "You don't have permission to remove members.") + return redirect("team_goal_detail", goal_id=goal_id) + + member = get_object_or_404(TeamGoalMember, id=member_id, team_goal=goal) + + # Prevent removing the creator + if member.user == goal.creator: + messages.error(request, "The team creator cannot be removed.") + return redirect("team_goal_detail", goal_id=goal_id) + + member.delete() + messages.success(request, f"{member.user.username} has been removed from the team.") + return redirect("team_goal_detail", goal_id=goal_id) + + +@login_required +def submit_team_proof(request, team_goal_id): + team_goal = get_object_or_404(TeamGoal, id=team_goal_id) + member = get_object_or_404(TeamGoalMember, team_goal=team_goal, user=request.user) + + if request.method == "POST": + form = TeamGoalCompletionForm(request.POST, request.FILES, instance=member) + if form.is_valid(): + form.save() + if not member.completed: + member.mark_completed() + return redirect("team_goal_detail", goal_id=team_goal.id) # Fixed here + + else: + form = TeamGoalCompletionForm(instance=member) + + return render(request, "teams/submit_proof.html", {"form": form, "team_goal": team_goal}) + + +@login_required +def delete_team_goal(request, goal_id): + """Delete a team goal.""" + goal = get_object_or_404(TeamGoal, id=goal_id) + + # Only creator can delete the goal + if request.user != goal.creator: + messages.error(request, "Only the creator can delete this team goal.") + return redirect("team_goal_detail", goal_id=goal_id) + + if request.method == "POST": + goal.delete() + messages.success(request, "Team goal has been deleted.") + return redirect("team_goals") + + return render(request, "teams/delete_confirm.html", {"goal": goal}) + + +@login_required +def virtual_classroom_list(request): + """View to list all virtual classrooms for the current user.""" + classrooms = VirtualClassroom.objects.filter(teacher=request.user) + return render( + request, + "virtual_classroom/list.html", + {"classrooms": classrooms, "user": request.user}, # Pass the user object which includes the profile + ) + + +@login_required +@require_POST +def join_global_virtual_classroom(request): + """Join (or create) the global virtual classroom and redirect to it.""" + + teacher = User.objects.filter(is_staff=True, is_active=True).order_by("-is_superuser", "date_joined").first() + + if not teacher: + messages.error(request, "No teacher is available to host the global virtual classroom yet.") + return redirect("index") + + classroom = ( + VirtualClassroom.objects.filter(name__iexact="Global Virtual Classroom", course__isnull=True) + .order_by("-created_at") + .first() + ) + + if not classroom: + classroom = VirtualClassroom.objects.create( + name="Global Virtual Classroom", + teacher=teacher, + is_active=True, + max_students=200, + ) + + # Ensure customization exists + VirtualClassroomCustomization.objects.get_or_create(classroom=classroom) + + # Add the current user as a participant + VirtualClassroomParticipant.objects.get_or_create(user=request.user, classroom=classroom) + + messages.success(request, "You're in! Welcome to the global virtual classroom.") + return redirect("virtual_classroom_detail", classroom_id=classroom.id) + + +@login_required +def virtual_classroom_create(request): + """View to create a new virtual classroom.""" + if request.method == "POST": + form = VirtualClassroomForm(request.POST, user=request.user) + if form.is_valid(): + classroom = form.save(commit=False) + classroom.teacher = request.user + classroom.save() + + # Create default customization settings + VirtualClassroomCustomization.objects.get_or_create( + classroom=classroom, + defaults={ + "wall_color": "#E6E2D7", + "floor_color": "#C7B299", + "desk_color": "#8B4513", + "chair_color": "#4B0082", + "board_color": "#005C53", + "number_of_rows": 5, + "desks_per_row": 6, + "has_plants": True, + "has_windows": True, + "has_bookshelf": True, + "has_clock": True, + "has_carpet": True, + }, + ) + + messages.success(request, "Virtual classroom created successfully!") + return redirect("virtual_classroom_customize", classroom_id=classroom.id) + else: + form = VirtualClassroomForm(user=request.user) + + return render(request, "virtual_classroom/create.html", {"form": form}) + + +@login_required +def virtual_classroom_customize(request, classroom_id): + """View to customize a virtual classroom.""" + classroom = get_object_or_404(VirtualClassroom, id=classroom_id, teacher=request.user) + + # Get or create customization settings + customization, created = VirtualClassroomCustomization.objects.get_or_create(classroom=classroom) + + if request.method == "POST": + form = VirtualClassroomCustomizationForm(request.POST, instance=customization) + if form.is_valid(): + form.save() + messages.success(request, "Classroom customization saved successfully!") + return redirect("virtual_classroom_detail", classroom_id=classroom.id) + else: + form = VirtualClassroomCustomizationForm(instance=customization) + + return render(request, "virtual_classroom/customize.html", {"form": form, "classroom": classroom}) + + +@login_required +def virtual_classroom_detail(request, classroom_id): + """View to display a virtual classroom.""" + classroom = get_object_or_404(VirtualClassroom, id=classroom_id) + + # Check if user is teacher or enrolled student + is_teacher = request.user == classroom.teacher + is_enrolled = False + + if classroom.course: + # For classrooms with a course, check course enrollments + is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() + else: + # For standalone classrooms, check VirtualClassroomParticipant table + is_enrolled = VirtualClassroomParticipant.objects.filter(classroom=classroom, user=request.user).exists() + + if not (is_teacher or is_enrolled): + messages.error(request, "You do not have access to this virtual classroom.") + if classroom.course: + return redirect("course_detail", slug=classroom.course.slug) + else: + return redirect("virtual_classroom_list") + + # Get or create customization settings to prevent DoesNotExist errors + customization, created = VirtualClassroomCustomization.objects.get_or_create( + classroom=classroom, + defaults={ + "wall_color": "#E6E2D7", + "floor_color": "#C7B299", + "desk_color": "#8B4513", + "chair_color": "#4B0082", + "board_color": "#005C53", + "number_of_rows": 5, + "desks_per_row": 6, + "has_plants": True, + "has_windows": True, + "has_bookshelf": True, + "has_clock": True, + "has_carpet": True, + }, + ) + + if request.method == "POST" and request.headers.get("X-Requested-With") == "XMLHttpRequest": + if not is_teacher: # Only teachers can customize + return JsonResponse({"status": "error", "message": "Only teachers can customize the classroom"}, status=403) + + try: + data = json.loads(request.body.decode("utf-8") or "{}") + + # Update customization settings + customization.wall_color = data.get("wall_color", customization.wall_color) + customization.floor_color = data.get("floor_color", customization.floor_color) + customization.desk_color = data.get("desk_color", customization.desk_color) + customization.chair_color = data.get("chair_color", customization.chair_color) + customization.board_color = data.get("board_color", customization.board_color) + customization.number_of_rows = data.get("number_of_rows", customization.number_of_rows) + customization.desks_per_row = data.get("desks_per_row", customization.desks_per_row) + customization.has_plants = data.get("has_plants", customization.has_plants) + customization.has_windows = data.get("has_windows", customization.has_windows) + customization.has_bookshelf = data.get("has_bookshelf", customization.has_bookshelf) + customization.has_clock = data.get("has_clock", customization.has_clock) + customization.has_carpet = data.get("has_carpet", customization.has_carpet) + + customization.save() + return JsonResponse({"status": "success"}) + except json.JSONDecodeError: + return JsonResponse({"status": "error", "message": "Invalid JSON data"}, status=400) + except Exception: + # Log the detailed exception for debugging + logger.exception("Error in virtual_classroom_detail customization") + return JsonResponse({"status": "error", "message": "An internal error occurred"}, status=500) + + # Get participants for the classroom + participants = VirtualClassroomParticipant.objects.filter(classroom=classroom).select_related("user") + + return render( + request, + "virtual_classroom/index.html", + { + "classroom": classroom, + "customization": customization, + "is_teacher": is_teacher, + "is_enrolled": is_enrolled, + "participants": participants, + }, + ) + + +@login_required +def virtual_classroom_edit(request, classroom_id): + """View to edit a virtual classroom.""" + classroom = get_object_or_404(VirtualClassroom, id=classroom_id, teacher=request.user) + + if request.method == "POST": + form = VirtualClassroomForm(request.POST, instance=classroom, user=request.user) + if form.is_valid(): + form.save() + messages.success(request, "Virtual classroom updated successfully!") + return redirect("virtual_classroom_detail", classroom_id=classroom.id) + else: + form = VirtualClassroomForm(instance=classroom, user=request.user) + + return render(request, "virtual_classroom/edit.html", {"form": form, "classroom": classroom}) + + +@login_required +def virtual_classroom_delete(request, classroom_id): + """View to delete a virtual classroom.""" + classroom = get_object_or_404(VirtualClassroom, id=classroom_id, teacher=request.user) + + if request.method == "POST": + classroom.delete() + messages.success(request, "Virtual classroom deleted successfully!") + return redirect("virtual_classroom_list") + + return render(request, "virtual_classroom/delete.html", {"classroom": classroom}) + + +@login_required +def classroom_blackboard(request, classroom_id): + """View for the classroom blackboard interaction.""" + classroom = get_object_or_404(VirtualClassroom, id=classroom_id) + + # Check if user is teacher or enrolled student + is_teacher = request.user == classroom.teacher + is_enrolled = False + if classroom.course: + is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() + + if not (is_teacher or is_enrolled): + messages.error(request, "You do not have access to this virtual classroom.") + return redirect("virtual_classroom_list") + + return render( + request, + "virtual_classroom/blackboard.html", + {"classroom": classroom, "is_teacher": is_teacher, "is_enrolled": is_enrolled}, + ) + + +@login_required +def classroom_student_desk(request, classroom_id, seat_id): + """View for individual student desk interaction.""" + classroom = get_object_or_404(VirtualClassroom, id=classroom_id) + + # Check if user is teacher or enrolled student + is_teacher = request.user == classroom.teacher + is_enrolled = False + if classroom.course: + is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() + + if not (is_teacher or is_enrolled): + messages.error(request, "You do not have access to this virtual classroom.") + return redirect("virtual_classroom_list") + + return render( + request, + "virtual_classroom/student_desk.html", + {"classroom": classroom, "seat_id": seat_id, "is_teacher": is_teacher, "is_enrolled": is_enrolled}, + ) + + +@login_required +@require_POST +def reset_attendance(request, classroom_id): + """Reset today's attendance for a classroom.""" + try: + classroom = get_object_or_404(VirtualClassroom, id=classroom_id) + + # Check if user is the teacher + if request.user != classroom.teacher: + messages.error(request, "Only the teacher can reset attendance.") + return redirect("classroom_attendance", classroom_id=classroom_id) + + # Get today's session and delete all attendance records + today = timezone.now().date() + today_start = timezone.make_aware(datetime.combine(today, datetime.min.time())) + today_end = timezone.make_aware(datetime.combine(today, datetime.max.time())) + + if classroom.course: + # For classrooms with courses, find and delete attendance for today's session + session = Session.objects.filter( + course=classroom.course, start_time__range=(today_start, today_end) + ).first() + + if session: + SessionAttendance.objects.filter(session=session).delete() + else: + # For classrooms without courses, find and delete standalone session attendance + session = Session.objects.filter( + title=f"Class on {today.strftime('%Y-%m-%d')}", start_time__range=(today_start, today_end) + ).first() + + if session: + SessionAttendance.objects.filter(session=session).delete() + + messages.success(request, "Today's attendance has been reset.") + return redirect("classroom_attendance", classroom_id=classroom_id) + + except Exception: + messages.error(request, "An error occurred while resetting attendance.") + return redirect("classroom_attendance", classroom_id=classroom_id) + + +@login_required +def classroom_attendance(request, classroom_id): + """View for managing classroom attendance.""" + classroom = get_object_or_404(VirtualClassroom, id=classroom_id) + + # Check if user is teacher or enrolled student + is_teacher = request.user == classroom.teacher + is_enrolled = False + + # Get all enrolled students + enrolled_students = [] + if classroom.course: + is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() + enrolled_students = ( + User.objects.filter(enrollments__course=classroom.course, enrollments__status="approved") + .select_related("profile") + .order_by("first_name", "last_name") + .distinct() + ) + else: + # For classrooms without a course, everyone is "enrolled" + is_enrolled = True + # Get users who are participants in this classroom (excluding teacher) + participant_user_ids = VirtualClassroomParticipant.objects.filter(classroom=classroom).values_list( + "user_id", flat=True + ) + + enrolled_students = ( + User.objects.filter(id__in=participant_user_ids) + .exclude(id=classroom.teacher.id) + .select_related("profile") + .order_by("first_name", "last_name") + .distinct() + ) + + if not (is_teacher or is_enrolled): + messages.error(request, "You do not have access to this virtual classroom.") + return redirect("virtual_classroom_list") + + # Get today's attendance records + today = timezone.now().date() + today_start = timezone.make_aware(datetime.combine(today, datetime.min.time())) + today_end = timezone.make_aware(datetime.combine(today, datetime.max.time())) + + # Get today's session + if classroom.course: + session = Session.objects.filter(course=classroom.course, start_time__range=(today_start, today_end)).first() + else: + session = Session.objects.filter( + title=f"Class on {today.strftime('%Y-%m-%d')}", start_time__range=(today_start, today_end) + ).first() + + # Get attendance records for today's session + attendance_records = ( + SessionAttendance.objects.filter(session=session, status="present").select_related("student") if session else [] + ) + + present_students = [record.student for record in attendance_records] + + context = { + "classroom": classroom, + "is_teacher": is_teacher, + "is_enrolled": is_enrolled, + "enrolled_students": enrolled_students, + "present_students": present_students, + "teacher": classroom.teacher, + } + + return render(request, "virtual_classroom/attendance.html", context) + + +@login_required +def update_student_attendance(request, classroom_id): + """View to update student attendance.""" + classroom = get_object_or_404(VirtualClassroom, id=classroom_id) + + # Check if user is teacher or enrolled student + is_teacher = request.user == classroom.teacher + is_enrolled = False + if classroom.course: + is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() + else: + is_enrolled = VirtualClassroomParticipant.objects.filter( + classroom=classroom, + user=request.user, + ).exists() + + if not (is_teacher or is_enrolled): + messages.error(request, "You do not have access to this virtual classroom.") + return redirect("virtual_classroom_list") + + if request.method == "POST": + try: + # Attempt to parse JSON payload; if it fails assume regular form submission + try: + data = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + data = {} + + student_id = data.get("student_id") or request.POST.get("student_id") + status = data.get("status") or request.POST.get("status") or "present" + + # If student_id is still missing, default to the current user (self-marking) + if not student_id: + student_id = request.user.id + + if not status: + status = "present" + + # Validate status value + allowed_status = {"present", "absent", "late", "excused"} + if status not in allowed_status: + return JsonResponse({"status": "error", "message": "Invalid status value"}, status=400) + + student = get_object_or_404(User, id=student_id) + + # Check if student is enrolled in the classroom + if classroom.course: + if not classroom.course.enrollments.filter(student=student, status="approved").exists(): + return JsonResponse( + {"status": "error", "message": "Student is not enrolled in this classroom"}, + status=400, + ) + else: + if not VirtualClassroomParticipant.objects.filter(classroom=classroom, user=student).exists(): + return JsonResponse( + {"status": "error", "message": "Student is not enrolled in this classroom"}, + status=400, + ) + + # Get today's session + today = timezone.now().date() + today_start = timezone.make_aware(datetime.combine(today, datetime.min.time())) + today_end = timezone.make_aware(datetime.combine(today, datetime.max.time())) + + if classroom.course: + session = Session.objects.filter( + course=classroom.course, start_time__range=(today_start, today_end) + ).first() + else: + session = Session.objects.filter( + title=f"Class on {today.strftime('%Y-%m-%d')}", start_time__range=(today_start, today_end) + ).first() + + if not session: + # Automatically create today's session if it doesn't exist + if classroom.course: + session = Session.objects.create( + course=classroom.course, + title=f"Class on {today.strftime('%Y-%m-%d')}", + start_time=today_start, + end_time=today_end, + ) + else: + session = Session.objects.create( + title=f"Class on {today.strftime('%Y-%m-%d')}", + start_time=today_start, + end_time=today_end, + course=None, + ) + + # Update or create attendance record + attendance, created = SessionAttendance.objects.get_or_create( + session=session, student=student, defaults={"status": status} + ) + + if not created: + attendance.status = status + attendance.save() + + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" + if is_ajax: + return JsonResponse({"status": "success"}) + + messages.success(request, "Attendance marked successfully.") + return redirect("classroom_attendance", classroom_id=classroom_id) + except json.JSONDecodeError: + return JsonResponse({"status": "error", "message": "Invalid JSON data"}, status=400) + except Exception: + # Log the detailed exception for debugging + logger.exception("Error in update_student_attendance") + return JsonResponse({"status": "error", "message": "An internal error occurred"}, status=500) + + return JsonResponse({"status": "error", "message": "Invalid request method"}, status=400) + + +@login_required +def get_student_attendance(request): + """Get a student's attendance data for a specific course.""" + if not request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": False, "message": "Invalid request"}, status=400) + + student_id = request.GET.get("student_id") + course_id = request.GET.get("course_id") + + if not all([student_id, course_id]): + return JsonResponse({"success": False, "message": "Missing required parameters"}, status=400) + + try: + course = Course.objects.get(id=course_id) + student = User.objects.get(id=student_id) + + # Check if user is authorized (must be the course teacher) + if request.user != course.teacher: + return JsonResponse( + {"success": False, "message": "Unauthorized: Only the course teacher can view this data"}, status=403 + ) + + # Get all attendance records for this student in this course + attendance_records = SessionAttendance.objects.filter(student=student, session__course=course).select_related( + "session" + ) + + # Format the data for the frontend + attendance_data = {} + for record in attendance_records: + attendance_data[record.session.id] = { + "status": record.status, + "notes": record.notes, + "created_at": record.created_at.isoformat(), + "updated_at": record.updated_at.isoformat(), + } + + return JsonResponse({"success": True, "attendance": attendance_data}) + + except Course.DoesNotExist: + return JsonResponse({"success": False, "message": "Course not found"}, status=404) + except User.DoesNotExist: + return JsonResponse({"success": False, "message": "Student not found"}, status=404) + except Exception: + return JsonResponse({"success": False, "message": "Error: get_student_attendance"}, status=500) + + +@login_required +@teacher_required +def add_student_to_course(request, slug): + course = get_object_or_404(Course, slug=slug) + if course.teacher != request.user: + return HttpResponseForbidden("You are not authorized to enroll students in this course.") + + # Check if course is full + if course.max_students and course.enrollments.count() >= course.max_students: + messages.error(request, "This course is full. Cannot enroll more students.") + return redirect("course_detail", slug=course.slug) + + if request.method == "POST": + form = StudentEnrollmentForm(request.POST) + if form.is_valid(): + email = form.cleaned_data["email"] + first_name = form.cleaned_data["first_name"] + last_name = form.cleaned_data["last_name"] + + # Try to find existing user + student = User.objects.filter(email=email).first() + + if student: + # Check if student is already enrolled + if Enrollment.objects.filter(course=course, student=student).exists(): + form.add_error(None, "This student is already enrolled in the course.") + else: + # Enroll existing student + enrollment = Enrollment.objects.create(course=course, student=student, status="approved") + messages.success(request, f"{student.get_full_name()} has been enrolled in the course.") + + # Send enrollment notifications + send_enrollment_confirmation(enrollment) + notify_teacher_new_enrollment(enrollment) + + # Send enrollment notification email to existing student + context = {"student": student, "course": course, "teacher": request.user, "is_existing_user": True} + html_message = render_to_string("emails/student_enrollment.html", context) + send_mail( + f"You have been enrolled in {course.title}", + f"You have been enrolled in {course.title} " + f"by {request.user.get_full_name() or request.user.username}.", + settings.DEFAULT_FROM_EMAIL, + [email], + html_message=html_message, + fail_silently=False, + ) + return redirect("course_detail", slug=course.slug) + else: + # Create new student account + try: + # Generate a unique username + timestamp = timezone.now().strftime("%Y%m%d%H%M%S") + generated_username = f"user_{timestamp}" + while User.objects.filter(username=generated_username).exists(): + generated_username = f"user_{timestamp}_{get_random_string(6)}" + + # Create new user + random_password = get_random_string(10) + student = User.objects.create_user( + username=generated_username, + email=email, + password=random_password, + first_name=first_name, + last_name=last_name, + ) + student.profile.is_teacher = False + student.profile.save() + + # Enroll the new student + enrollment = Enrollment.objects.create(course=course, student=student, status="approved") + messages.success(request, f"{first_name} {last_name} has been enrolled in the course.") + + # Send enrollment notifications + send_enrollment_confirmation(enrollment) + notify_teacher_new_enrollment(enrollment) + + # Send enrollment notification and password reset link to new student + reset_link = request.build_absolute_uri(reverse("account_reset_password")) + context = { + "student": student, + "course": course, + "teacher": request.user, + "reset_link": reset_link, + "is_existing_user": False, + } + html_message = render_to_string("emails/student_enrollment.html", context) + send_mail( + f"You have been enrolled in {course.title}", + f"You have been enrolled in {course.title} " + f"by {request.user.get_full_name() or request.user.username}. " + f"Please visit {reset_link} to set your password.", + settings.DEFAULT_FROM_EMAIL, + [email], + html_message=html_message, + fail_silently=False, + ) + return redirect("course_detail", slug=course.slug) + except IntegrityError: + form.add_error(None, "Failed to create user account. Please try again.") + else: + form = StudentEnrollmentForm() + + return render(request, "courses/add_student.html", {"form": form, "course": course}) + + +def donate(request): + """Display the donation page with options for one-time donations and subscriptions.""" + # Get recent public donations to display + recent_donations = Donation.objects.filter(status="completed", anonymous=False).order_by("-created_at")[:5] + + # Calculate total donations + total_donations = Donation.objects.filter(status="completed").aggregate(total=Sum("amount"))["total"] or 0 + + # Preset donation amounts for buttons + donation_amounts = [5, 10, 25, 50, 100] + + context = { + "stripe_public_key": settings.STRIPE_PUBLISHABLE_KEY, + "recent_donations": recent_donations, + "total_donations": total_donations, + "donation_amounts": donation_amounts, + } + + return render(request, "donate.html", context) + + +@csrf_exempt # Add CSRF exemption for Stripe +def create_donation_payment_intent(request: HttpRequest) -> JsonResponse: + """Create a payment intent for a one-time donation with multiple payment methods.""" + if request.method != "POST": + return JsonResponse({"error": "Invalid request method"}, status=400) + + try: + data = json.loads(request.body) + amount = data.get("amount") + message = data.get("message", "") + anonymous = data.get("anonymous", False) + email = data.get("email", "") + + if not amount or float(amount) <= 0: + return JsonResponse({"error": "Invalid donation amount"}, status=400) + + if not email: + return JsonResponse({"error": "Email is required"}, status=400) + + # Convert amount to cents for Stripe + amount_cents = int(float(amount) * 100) + + # Create a payment intent with multiple payment method types + intent = stripe.PaymentIntent.create( + amount=amount_cents, + currency="usd", + automatic_payment_methods={"enabled": True, "allow_redirects": "always"}, + receipt_email=email, + metadata={ + "donation_type": "one_time", + "user_id": str(request.user.id) if request.user.is_authenticated else None, + "message": message[:100] if message else "", + "anonymous": "true" if anonymous else "false", + "email": email, + }, + ) + + # Create a donation record + donation = Donation.objects.create( + user=request.user if request.user.is_authenticated else None, + email=email, + amount=amount, + donation_type="one_time", + status="pending", + stripe_payment_intent_id=intent.id, + message=message, + anonymous=anonymous, + ) + + return JsonResponse({"clientSecret": intent.client_secret, "donation_id": donation.id}) + + except Exception as e: + # Log the detailed exception for debugging + logger.exception("Error in create_donation_payment_intent: %s", str(e)) + return JsonResponse({"error": "An internal error occurred"}, status=400) + + +@csrf_exempt +def create_donation_subscription(request: HttpRequest) -> JsonResponse: + """Create a subscription for recurring donations with multiple payment methods.""" + if request.method != "POST": + return JsonResponse({"error": "Invalid request method"}, status=400) + + try: + data = json.loads(request.body) + amount = data.get("amount") + message = data.get("message", "") + anonymous = data.get("anonymous", False) + email = data.get("email", "") + + if not amount or float(amount) <= 0: + return JsonResponse({"error": "Invalid donation amount"}, status=400) + + if not email: + return JsonResponse({"error": "Email is required"}, status=400) + + # Convert amount to cents for Stripe + amount_cents = int(float(amount) * 100) + + # Create or get customer + customer = None + # Try to retrieve existing customer for authenticated users + if request.user.is_authenticated: + try: + membership = request.user.membership + if membership.stripe_customer_id: + customer = stripe.Customer.retrieve(membership.stripe_customer_id) + except (UserMembership.DoesNotExist, stripe.error.InvalidRequestError): + # No membership or invalid customer ID + customer = None + + # Create new customer if needed + if not customer: + customer = stripe.Customer.create( + email=email, + metadata={ + "user_id": str(request.user.id) if request.user.is_authenticated else None, + }, + ) + if request.user.is_authenticated: + # Store customer ID in user membership + membership, _ = UserMembership.objects.get_or_create( + user=request.user, defaults={"plan_id": 1, "stripe_customer_id": customer.id} + ) + if not membership.stripe_customer_id: + membership.stripe_customer_id = customer.id + membership.save() + + # Create a PaymentIntent for the first payment with setup_future_usage + payment_intent = stripe.PaymentIntent.create( + amount=amount_cents, + currency="usd", + customer=customer.id, + setup_future_usage="off_session", + automatic_payment_methods={"enabled": True, "allow_redirects": "always"}, + metadata={ + "donation_type": "subscription", + "user_id": str(request.user.id) if request.user.is_authenticated else None, + "message": message[:100] if message else "", + "anonymous": "true" if anonymous else "false", + "email": email, + }, + ) + + # Create donation record + donation = Donation.objects.create( + user=request.user if request.user.is_authenticated else None, + email=email, + amount=amount, + donation_type="subscription", + status="pending", + stripe_payment_intent_id=payment_intent.id, + stripe_customer_id=customer.id, + message=message, + anonymous=anonymous, + ) + + return JsonResponse({"clientSecret": payment_intent.client_secret, "donation_id": donation.id}) + + except Exception as e: + return JsonResponse({"error": str(e)}, status=400) + + +@csrf_exempt +def donation_webhook(request): + """Handle Stripe webhooks for donations.""" + payload = request.body + sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") + webhook_secret = settings.STRIPE_WEBHOOK_SECRET + + try: + event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret) + logger.info("Received Stripe webhook: %s", event.type) + except ValueError as e: + logger.exception("Invalid payload: %s", str(e)) + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError as e: + logger.exception("Invalid signature: %s", str(e)) + return HttpResponse(status=400) + + try: + # Handle payment intent events + if event.type == "payment_intent.succeeded": + payment_intent = event.data.object + logger.info("Processing successful payment: %s", payment_intent.id) + handle_successful_donation_payment(payment_intent) + elif event.type == "payment_intent.payment_failed": + payment_intent = event.data.object + logger.info("Processing failed payment: %s", payment_intent.id) + handle_failed_donation_payment(payment_intent) + + # Handle subscription events + elif event.type == "customer.subscription.created": + subscription = event.data.object + logger.info("Processing new subscription: %s", subscription.id) + handle_subscription_created(subscription) + elif event.type == "customer.subscription.updated": + subscription = event.data.object + logger.info("Processing subscription update: %s", subscription.id) + handle_subscription_updated(subscription) + elif event.type == "customer.subscription.deleted": + subscription = event.data.object + logger.info("Processing subscription cancellation: %s", subscription.id) + handle_subscription_cancelled(subscription) + elif event.type == "invoice.payment_succeeded": + invoice = event.data.object + logger.info("Processing successful invoice payment: %s", invoice.id) + handle_invoice_paid(invoice) + elif event.type == "invoice.payment_failed": + invoice = event.data.object + logger.info("Processing failed invoice payment: %s", invoice.id) + handle_invoice_failed(invoice) + + return HttpResponse(status=200) + except Exception: + logger.exception("Error processing webhook %s", event.type) + return HttpResponse(status=500) + + +def handle_successful_donation_payment(payment_intent: stripe.PaymentIntent) -> None: + """Handle successful one-time donation payments.""" + try: + logger.info("Finding donation for payment intent: %s", payment_intent.id) + # Find the donation by payment intent ID + donation = Donation.objects.get(stripe_payment_intent_id=payment_intent.id) + + # Only update if not already completed + if donation.status != "completed": + logger.info("Marking donation %s as completed", donation.id) + donation.status = "completed" + donation.save() + + # Send thank you email + send_donation_thank_you_email(donation) + else: + logger.info("Donation %s already completed", donation.id) + + except Donation.DoesNotExist: + logger.warning( + ( + "No donation found for payment intent: %s. This may indicate a payment intended for another system " + "or a database inconsistency." + ), + payment_intent.id, + ) + except Exception: + logger.exception("Error handling successful payment %s", payment_intent.id) + raise # Re-raise to allow webhook to handle the error + + +def handle_failed_donation_payment(payment_intent): + """Handle failed one-time donation payments.""" + try: + # Find the donation by payment intent ID + donation = Donation.objects.get(stripe_payment_intent_id=payment_intent.id) + donation.status = "failed" + donation.save() + + except Donation.DoesNotExist: + # This might be a payment for something else + pass + + +def handle_subscription_created(subscription): + """Handle newly created subscriptions.""" + try: + # Find the donation by subscription ID + donation = Donation.objects.get(stripe_subscription_id=subscription.id) + donation.status = "completed" if subscription.status == "active" else "pending" + donation.save() + + except Donation.DoesNotExist: + # This might be a subscription for something else + pass + + +def handle_subscription_updated(subscription): + """Handle subscription updates.""" + try: + # Find the donation by subscription ID + donation = Donation.objects.get(stripe_subscription_id=subscription.id) + + # Update status based on subscription status + if subscription.status == "active": + donation.status = "completed" + elif subscription.status == "past_due": + donation.status = "pending" + elif subscription.status == "canceled": + donation.status = "cancelled" + + donation.save() + + except Donation.DoesNotExist: + # This might be a subscription for something else + pass + + +def handle_subscription_cancelled(subscription): + """Handle cancelled subscriptions.""" + try: + # Find the donation by subscription ID + donation = Donation.objects.get(stripe_subscription_id=subscription.id) + donation.status = "cancelled" + donation.save() + + except Donation.DoesNotExist: + # This might be a subscription for something else + pass + + +def handle_invoice_paid(invoice): + """Handle successful subscription invoice payments.""" + if invoice.subscription: + try: + # Find the donation by subscription ID + donation = Donation.objects.get(stripe_subscription_id=invoice.subscription) + + # Create a new donation record for this payment + Donation.objects.create( + user=donation.user, + email=donation.email, + amount=donation.amount, + donation_type="subscription", + status="completed", + stripe_subscription_id=donation.stripe_subscription_id, + stripe_customer_id=donation.stripe_customer_id, + message=donation.message, + anonymous=donation.anonymous, + ) + + # Send thank you email + send_donation_thank_you_email(donation) + + except Donation.DoesNotExist: + # This might be a subscription for something else + pass + + +def handle_invoice_failed(invoice): + """Handle failed subscription invoice payments.""" + if invoice.subscription: + try: + # Find the donation by subscription ID + donation = Donation.objects.get(stripe_subscription_id=invoice.subscription) + + # Create a new donation record for this failed payment + Donation.objects.create( + user=donation.user, + email=donation.email, + amount=donation.amount, + donation_type="subscription", + status="failed", + stripe_subscription_id=donation.stripe_subscription_id, + stripe_customer_id=donation.stripe_customer_id, + message=donation.message, + anonymous=donation.anonymous, + ) + + except Donation.DoesNotExist: + # This might be a subscription for something else + pass + + +def send_donation_thank_you_email(donation): + """Send a thank you email for donations.""" + subject = "Thank You for Your Donation!" + from_email = settings.DEFAULT_FROM_EMAIL + to_email = donation.email + + # Prepare context for email template + context = { + "donation": donation, + "site_name": settings.SITE_NAME, + } + + # Render email template + html_message = render_to_string("emails/donation_thank_you.html", context) + plain_message = strip_tags(html_message) + + # Send email + send_mail(subject, plain_message, from_email, [to_email], html_message=html_message) + + +def donation_success(request): + """Display a success page after a successful donation.""" + donation_id = request.GET.get("donation_id") + payment_intent = request.GET.get("payment_intent") + redirect_status = request.GET.get("redirect_status") + + # Ensure we have the required donation_id + if not donation_id: + messages.error(request, "No donation information found.") + return redirect("donate") + + try: + donation = Donation.objects.get(id=donation_id) + + # If the redirect indicates success and a payment intent exists, verify with Stripe. + if redirect_status == "succeeded" and payment_intent: + # Double check with Stripe that the payment was successful + try: + # Retrieve PaymentIntent from Stripe to confirm payment status. + stripe_payment = stripe.PaymentIntent.retrieve(payment_intent) + if stripe_payment.status == "succeeded": + donation.status = "completed" + donation.save() + # Send thank you email if not already sent + send_donation_thank_you_email(donation) + except stripe.error.StripeError: + logger.exception("Error verifying payment intent") + + # Retry logic for temporary Stripe failures using Django's cache. + cache_key = f"retry_verify_payment_{payment_intent}" + retry_count = cache.get(cache_key, 0) + + if retry_count < 3: + cache.set(cache_key, retry_count + 1, 3600) # Retry after 1 hour + # You could enqueue a Celery task or use another background task system + # to retry the verification process + logger.warning("Retry %d/3 scheduled for payment verification: %s", retry_count + 1, payment_intent) + else: + logger.error("Max retries reached for payment verification: %s", payment_intent) + + # Continue to show the success page even if verification fails; + # the webhook will eventually update the status + + # If the donation status is now completed, show the success page. + if donation.status == "completed": + context = { + "donation": donation, + } + return render(request, "donation_success.html", context) + + # If donation status is not completed, alert the user and redirect. + messages.error(request, "Donation has not been successfully processed.") + return redirect("donate") + + except Donation.DoesNotExist: + messages.error(request, "Invalid donation ID.") + return redirect("donate") + + except stripe.error.StripeError: + logger.exception("Stripe error in donation_success view") + messages.error(request, "A payment processing error occurred. Please check your payment details.") + return redirect("donate") + + except Exception: + logger.exception("Unexpected error in donation_success view") + messages.error(request, "An unexpected error occurred while processing your donation.") + return redirect("donate") + + +def donation_cancel(request): + """Handle donation cancellation.""" + return redirect("donate") + + +def educational_videos_list(request: HttpRequest) -> HttpResponse: + """View for listing educational videos with requests included at the bottom.""" + # Get category filter from query params + selected_category = request.GET.get("category") + + # Base querysets + videos = EducationalVideo.objects.select_related("uploader", "category").order_by("-uploaded_at") + video_requests = VideoRequest.objects.select_related("requester", "category", "fulfilled_by").order_by( + "-created_at" + ) + + # Apply category filter if provided + if selected_category: + videos = videos.filter(category__slug=selected_category) + video_requests = video_requests.filter(category__slug=selected_category) + selected_category_obj = get_object_or_404(Subject, slug=selected_category) + selected_category_display = selected_category_obj.name + else: + selected_category_display = None + + # Paginate videos (9 per page) + paginator = Paginator(videos, 9) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + # Limit video requests to 5 + video_requests = video_requests[:5] + video_requests_paginated = VideoRequest.objects.count() > 5 + + # Category counts for sidebar + category_counts = dict( + EducationalVideo.objects.values("category__slug") + .annotate(count=Count("id")) + .values_list("category__slug", "count"), + ) + + # Get all subjects + subjects = Subject.objects.all().order_by("order", "name") + + context = { + "videos": page_obj, + "is_paginated": page_obj.has_other_pages(), + "page_obj": page_obj, + "subjects": subjects, + "selected_category": selected_category, + "selected_category_display": selected_category_display, + "category_counts": category_counts, + "video_requests": video_requests, + "video_requests_paginated": video_requests_paginated, + } + + return render(request, "videos/list.html", context) + + +def fetch_video_oembed(video_url): + """ + Hits YouTube or Vimeo's oEmbed endpoint and returns a dict + containing 'title' and 'description' (if available). + """ + # YouTube IDs are always 11 chars + yt_match = re.search(r"(?:v=|youtu\.be/|embed/|shorts/)([\w-]{11})", video_url) + if yt_match: + video_id = yt_match.group(1) + endpoint = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json" + else: + # Vimeo URLs look like vimeo.com/12345678… + vm_match = re.search(r"vimeo\.com/(?:video/)?(\d+)", video_url) + if vm_match: + endpoint = f"https://vimeo.com/api/oembed.json?url={video_url}" + else: + return {} + + try: + resp = requests.get(endpoint, timeout=3) + if resp.ok: + data = resp.json() + return { + "title": data.get("title", "").strip(), + "description": data.get("description", "").strip(), + } + except requests.RequestException: + pass + + return {} + + +def upload_educational_video(request): + """ + Handles GET → render form, POST → save video. + If user leaves title/description blank, we back‑fill from YouTube/Vimeo. + """ + if request.method == "POST": + form = EducationalVideoForm(request.POST) + if form.is_valid(): + video = form.save(commit=False) + if request.user.is_authenticated: + video.uploader = request.user + + # auto‑fetch metadata if missing + if not video.title.strip() or not video.description.strip(): + info = fetch_video_oembed(video.video_url) + if not video.title.strip() and info.get("title"): + video.title = info["title"] + if not video.description.strip() and info.get("description"): + video.description = info["description"] + + video.save() + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "message": "Video added successfully!"}) + return redirect("educational_videos_list") + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + error_text = " ".join(f"{fld}: {', '.join(errs)}." for fld, errs in form.errors.items()) + return JsonResponse({"success": False, "error": error_text}, status=400) + + else: + form = EducationalVideoForm() + + return render(request, "videos/upload.html", {"form": form}) + + +def certificate_detail(request, certificate_id): + certificate = get_object_or_404(Certificate, certificate_id=certificate_id) + if request.user != certificate.user and not request.user.is_staff: + return HttpResponseForbidden("You don't have permission to view this certificate") + context = { + "certificate": certificate, + } + return render(request, "courses/certificate_detail.html", context) + + +@login_required +def generate_certificate(request, enrollment_id): + # Retrieve the enrollment for the current user + enrollment = get_object_or_404(Enrollment, id=enrollment_id, student=request.user) + # Ensure the course is completed before generating a certificate + if enrollment.status != "completed": + messages.error(request, "You can only generate a certificate for a completed course.") + return redirect("student_dashboard") + + # Check if a certificate already exists for this course and user + certificate = Certificate.objects.filter(user=request.user, course=enrollment.course).first() + if certificate: + messages.info(request, "Certificate already generated.") + return redirect("certificate_detail", certificate_id=certificate.certificate_id) + + # Create a new certificate record manually + certificate = Certificate.objects.create(user=request.user, course=enrollment.course) + messages.success(request, "Certificate generated successfully!") + return redirect("certificate_detail", certificate_id=certificate.certificate_id) + + +@login_required +def tracker_list(request): + trackers = ProgressTracker.objects.filter(user=request.user).order_by("-updated_at") + return render(request, "trackers/list.html", {"trackers": trackers}) + + +@login_required +def create_tracker(request): + if request.method == "POST": + form = ProgressTrackerForm(request.POST) + if form.is_valid(): + tracker = form.save(commit=False) + tracker.user = request.user + tracker.save() + return redirect("tracker_detail", tracker_id=tracker.id) + else: + form = ProgressTrackerForm() + return render(request, "trackers/form.html", {"form": form, "title": "Create Progress Tracker"}) + + +@login_required +def update_tracker(request, tracker_id): + tracker = get_object_or_404(ProgressTracker, id=tracker_id, user=request.user) + + if request.method == "POST": + form = ProgressTrackerForm(request.POST, instance=tracker) + if form.is_valid(): + form.save() + return redirect("tracker_detail", tracker_id=tracker.id) + else: + form = ProgressTrackerForm(instance=tracker) + return render(request, "trackers/form.html", {"form": form, "tracker": tracker, "title": "Update Progress Tracker"}) + + +@login_required +def tracker_detail(request, tracker_id): + tracker = get_object_or_404(ProgressTracker, id=tracker_id, user=request.user) + embed_url = request.build_absolute_uri(f"/trackers/embed/{tracker.embed_code}/") + return render(request, "trackers/detail.html", {"tracker": tracker, "embed_url": embed_url}) + + +@login_required +def update_progress(request, tracker_id): + if request.method == "POST" and request.headers.get("X-Requested-With") == "XMLHttpRequest": + tracker = get_object_or_404(ProgressTracker, id=tracker_id, user=request.user) + + try: + new_value = int(request.POST.get("current_value", tracker.current_value)) + tracker.current_value = new_value + tracker.save() + + return JsonResponse( + {"success": True, "percentage": tracker.percentage, "current_value": tracker.current_value} + ) + except ValueError: + return JsonResponse({"success": False, "error": "Invalid value"}, status=400) + return JsonResponse({"success": False, "error": "Invalid request"}, status=400) + + +@xframe_options_exempt +def embed_tracker(request, embed_code): + tracker = get_object_or_404(ProgressTracker, embed_code=embed_code, public=True) + return render(request, "trackers/embed.html", {"tracker": tracker}) + + +@login_required +def streak_detail(request): + """Display the user's learning streak.""" + if not request.user.is_authenticated: + return redirect("account_login") + streak, created = LearningStreak.objects.get_or_create(user=request.user) + return render(request, "streak_detail.html", {"streak": streak}) + + +@login_required +def waiting_room_list(request): + """View for displaying waiting rooms categorized by status.""" + # Get waiting rooms by status + open_rooms = WaitingRoom.objects.filter(status="open") + fulfilled_rooms = WaitingRoom.objects.filter(status="fulfilled") + closed_rooms = WaitingRoom.objects.filter(status="closed") + + # Get waiting rooms created by the user + user_created_rooms = WaitingRoom.objects.filter(creator=request.user) + + # Get waiting rooms joined by the user + user_joined_rooms = request.user.joined_waiting_rooms.all() + + # Process topics for all waiting rooms + all_rooms = ( + list(open_rooms) + + list(fulfilled_rooms) + + list(closed_rooms) + + list(user_created_rooms) + + list(user_joined_rooms) + ) + room_topics = {} + for room in all_rooms: + room_topics[room.id] = [topic.strip() for topic in room.topics.split(",") if topic.strip()] + + context = { + "open_rooms": open_rooms, + "fulfilled_rooms": fulfilled_rooms, + "closed_rooms": closed_rooms, + "user_created_rooms": user_created_rooms, + "user_joined_rooms": user_joined_rooms, + "room_topics": room_topics, + } + return render(request, "waiting_room/list.html", context) + + +def find_matching_courses(waiting_room): + """Find courses that match the waiting room's subject and topics.""" + # Get courses with matching subject name (case-insensitive) + matching_courses = Course.objects.filter(subject__iexact=waiting_room.subject, status="published") + + # Filter courses that have all required topics + required_topics = {t.strip().lower() for t in waiting_room.topics.split(",") if t.strip()} + + # Further filter courses by checking if their topics contain all required topics + final_matches = [] + for course in matching_courses: + course_topics = {t.strip().lower() for t in course.topics.split(",") if t.strip()} + if course_topics.issuperset(required_topics): + final_matches.append(course) + + return final_matches + + +def waiting_room_detail(request, waiting_room_id): + """View for displaying details of a waiting room.""" + waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) + + # Check if the user is a participant + is_participant = request.user.is_authenticated and request.user in waiting_room.participants.all() + + # Check if the user is the creator + is_creator = request.user.is_authenticated and request.user == waiting_room.creator + + # Check if the user is a teacher + is_teacher = request.user.is_authenticated and hasattr(request.user, "profile") and request.user.profile.is_teacher + + context = { + "waiting_room": waiting_room, + "is_participant": is_participant, + "is_creator": is_creator, + "is_teacher": is_teacher, + "participant_count": waiting_room.participants.count() + 1, # Add 1 to include the creator + "topic_list": [topic.strip() for topic in waiting_room.topics.split(",") if topic.strip()], + } + return render(request, "waiting_room/detail.html", context) + + +@login_required +def join_waiting_room(request, waiting_room_id): + """View for joining a waiting room.""" + waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) + + # Check if the waiting room is open + if waiting_room.status != "open": + messages.error(request, "This waiting room is no longer open for joining.") + return redirect("waiting_room_list") + + # Add the user as a participant if not already + if request.user not in waiting_room.participants.all(): + waiting_room.participants.add(request.user) + messages.success(request, f"You have joined the waiting room: {waiting_room.title}") + else: + messages.info(request, "You are already a participant in this waiting room.") + + return redirect("waiting_room_detail", waiting_room_id=waiting_room.id) + + +@login_required +def leave_waiting_room(request, waiting_room_id): + """View for leaving a waiting room.""" + waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) + + # Remove the user from participants + if request.user in waiting_room.participants.all(): + waiting_room.participants.remove(request.user) + messages.success(request, f"You have left the waiting room: {waiting_room.title}") + else: + messages.info(request, "You are not a participant in this waiting room.") + + return redirect("waiting_room_list") + + +def is_superuser(user): + return user.is_superuser + + +@user_passes_test(is_superuser) +def sync_github_milestones(request): + """Sync GitHub milestones with forum topics.""" + github_repo = "alphaonelabs/alphaonelabs-education-website" + milestones_url = f"https://api.github.com/repos/{github_repo}/milestones" + + try: + # Get GitHub milestones + response = requests.get(milestones_url) + response.raise_for_status() + milestones = response.json() + + # Get or create a forum category for milestones + category, created = ForumCategory.objects.get_or_create( + name="GitHub Milestones", + defaults={ + "slug": "github-milestones", + "description": "Discussions about GitHub milestones and project roadmap", + "icon": "fa-github", + }, + ) + + # Count for tracking + created_count = 0 + updated_count = 0 + + for milestone in milestones: + milestone_title = milestone["title"] + milestone_description = milestone["description"] or "No description provided." + milestone_url = milestone["html_url"] + milestone_state = milestone["state"] + open_issues = milestone["open_issues"] + closed_issues = milestone["closed_issues"] + due_date = milestone.get("due_on", "No due date") + + # Format content with progress information + progress = 0 + if open_issues + closed_issues > 0: + progress = (closed_issues / (open_issues + closed_issues)) * 100 + + content = f""" +## Milestone: {milestone_title} + +{milestone_description} + +**State:** {milestone_state} +**Progress:** {progress:.1f}% ({closed_issues} closed / {open_issues} open issues) +**Due Date:** {due_date} + +[View on GitHub]({milestone_url}) + """ + + # Try to find an existing topic for this milestone + topic = ForumTopic.objects.filter( + category=category, title__startswith=f"Milestone: {milestone_title}" + ).first() + + if topic: + # Update existing topic + topic.content = content + topic.is_pinned = milestone_state == "open" # Pin open milestones + topic.save() + updated_count += 1 + else: + # Create new topic + # Use the first superuser as the author + author = User.objects.filter(is_superuser=True).first() + if author: + ForumTopic.objects.create( + category=category, + title=f"Milestone: {milestone_title}", + content=content, + author=author, + is_pinned=(milestone_state == "open"), + ) + created_count += 1 + + if created_count or updated_count: + messages.success( + request, f"Successfully synced GitHub milestones: {created_count} created, {updated_count} updated." + ) + else: + messages.info(request, "No GitHub milestones to sync.") + + except requests.exceptions.RequestException as e: + messages.error(request, f"Error fetching GitHub milestones: {str(e)}") + except Exception as e: + messages.error(request, f"Error syncing milestones: {str(e)}") + + return redirect("forum_categories") + + +@login_required +def toggle_course_status(request, slug): + """Toggle a course between draft and published status""" + course = get_object_or_404(Course, slug=slug) + + # Check if user is the course teacher + if request.user != course.teacher: + messages.error(request, "Only the course teacher can modify course status!") + return redirect("course_detail", slug=slug) + + # Toggle the status between draft and published + if course.status == "draft": + course.status = "published" + messages.success(request, "Course has been published successfully!") + elif course.status == "published": + course.status = "draft" + messages.success(request, "Course has been unpublished and is now in draft mode.") + # Note: We don't toggle from/to 'archived' status as that's a separate action + + course.save() + return redirect("course_detail", slug=slug) + + +def public_profile(request, username): + user = get_object_or_404(User, username=username) + + try: + profile = user.profile + except Profile.DoesNotExist: + # Instead of raising Http404, we call custom_404. + return custom_404(request, "Profile not found.") + + if not profile.is_profile_public: + return custom_404(request, "Profile not found.") + + context = {"profile": profile} + + if profile.is_teacher: + courses = Course.objects.filter(teacher=user) + total_students = sum(course.enrollments.filter(status="approved").count() for course in courses) + context.update( + { + "teacher_stats": { + "courses": courses, + "total_courses": courses.count(), + "total_students": total_students, + } + } + ) + else: + enrollments = Enrollment.objects.filter(student=user) + completed_enrollments = enrollments.filter(status="completed") + total_courses = enrollments.count() + total_completed = completed_enrollments.count() + total_progress = 0 + progress_count = 0 + for enrollment in enrollments: + progress, _ = CourseProgress.objects.get_or_create(enrollment=enrollment) + total_progress += progress.completion_percentage + progress_count += 1 + avg_progress = round(total_progress / progress_count) if progress_count > 0 else 0 + context.update( + { + "total_courses": total_courses, + "total_completed": total_completed, + "avg_progress": avg_progress, + "completed_courses": completed_enrollments, + } + ) + + return render(request, "public_profile_detail.html", context) + + +class GradeableLinkListView(ListView): + """View to display all submitted links that can be graded.""" + + model = GradeableLink + template_name = "grade_links/link_list.html" + context_object_name = "links" + paginate_by = 10 + + +class GradeableLinkDetailView(DetailView): + """View to display details about a specific link and its grades.""" + + model = GradeableLink + template_name = "grade_links/link_detail.html" + context_object_name = "link" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Check if user is authenticated + if self.request.user.is_authenticated: + # Check if the user has already graded this link + try: + user_grade = LinkGrade.objects.get(link=self.object, user=self.request.user) + context["user_grade"] = user_grade + context["grade_form"] = LinkGradeForm(instance=user_grade) + except LinkGrade.DoesNotExist: + context["grade_form"] = LinkGradeForm() + + # Get all grades for this link + context["grades"] = self.object.grades.all() + + return context + + +class GradeableLinkCreateView(LoginRequiredMixin, CreateView): + """View to submit a new link for grading.""" + + model = GradeableLink + form_class = GradeableLinkForm + template_name = "grade_links/submit_link.html" + success_url = reverse_lazy("gradeable_link_list") + + def form_valid(self, form): + form.instance.user = self.request.user + messages.success(self.request, "Your link has been submitted for grading!") + return super().form_valid(form) + + +@login_required +def grade_link(request, pk): + """View to grade a link.""" + link = get_object_or_404(GradeableLink, pk=pk) + + # Prevent users from grading their own links + if link.user == request.user: + messages.error(request, "You cannot grade your own submissions!") + return redirect("gradeable_link_detail", pk=link.pk) + + # Check if the user has already graded this link + try: + user_grade = LinkGrade.objects.get(link=link, user=request.user) + except LinkGrade.DoesNotExist: + user_grade = None + + if request.method == "POST": + form = LinkGradeForm(request.POST, instance=user_grade) + if form.is_valid(): + grade = form.save(commit=False) + grade.link = link + grade.user = request.user + grade.save() + messages.success(request, "Your grade has been submitted!") + return redirect("gradeable_link_detail", pk=link.pk) + else: + form = LinkGradeForm(instance=user_grade) + + return render( + request, + "grade_links/grade_link.html", + { + "form": form, + "link": link, + }, + ) + + +def duplicate_session(request, session_id): + """Duplicate a session to next week.""" + # Get the original session + session = get_object_or_404(Session, id=session_id) + course = session.course + + # Check if user is the course teacher + if request.user != course.teacher: + messages.error(request, "Only the course teacher can duplicate sessions!") + return redirect("course_detail", slug=course.slug) + + # Create a new session with the same properties but dates shifted forward by a week + new_session = Session( + course=course, + title=session.title, + description=session.description, + is_virtual=session.is_virtual, + meeting_link=session.meeting_link, + meeting_id="", # Clear meeting ID as it will be a new meeting + location=session.location, + price=session.price, + enable_rollover=session.enable_rollover, + rollover_pattern=session.rollover_pattern, + ) + + # Set dates one week later + time_shift = timezone.timedelta(days=7) + new_session.start_time = session.start_time + time_shift + new_session.end_time = session.end_time + time_shift + + # Save the new session + new_session.save() + msg = f"Session '{session.title}' duplicated for {new_session.start_time.strftime('%b %d, %Y')}" + messages.success(request, msg) + + return redirect("course_detail", slug=course.slug) + + +def run_create_test_data(request): + """Run the create_test_data management command and redirect to homepage.""" + from django.conf import settings + + if not settings.DEBUG: + messages.error(request, "This action is only available in debug mode.") + return redirect("index") + + try: + call_command("create_test_data") + messages.success(request, "Test data has been created successfully!") + except Exception as e: + messages.error(request, f"Error creating test data: {str(e)}") + + return redirect("index") + + +@login_required +@require_POST +def teacher_update_student_attendance(request, classroom_id): + """Handle student attendance marking.""" + try: + classroom = VirtualClassroom.objects.get(id=classroom_id) + student = request.user # Student marks their own attendance + + # Check if student is enrolled + is_enrolled = False + if classroom.course: + is_enrolled = classroom.course.enrollments.filter(student=student, status="approved").exists() + else: + # For classrooms without a course, check VirtualClassroomParticipant table + is_enrolled = VirtualClassroomParticipant.objects.filter(classroom=classroom, user=student).exists() + + if not is_enrolled: + return JsonResponse({"success": False, "message": "You are not enrolled in this class"}, status=403) + + # Get or create today's session + today = timezone.now().date() + today_start = timezone.make_aware(datetime.combine(today, datetime.min.time())) + today_end = timezone.make_aware(datetime.combine(today, datetime.max.time())) + + if classroom.course: + session = Session.objects.filter( + course=classroom.course, + start_time__range=(today_start, today_end), + ).first() + if not session: + session = Session.objects.create( + course=classroom.course, + title=f"Class on {today.strftime('%Y-%m-%d')}", + start_time=today_start, + end_time=today_end, + ) + else: + session = Session.objects.filter( + title=f"Class on {today.strftime('%Y-%m-%d')}", + start_time__range=(today_start, today_end), + course__isnull=True, + ).first() + if not session: + session = Session.objects.create( + title=f"Class on {today.strftime('%Y-%m-%d')}", + start_time=today_start, + end_time=today_end, + course=None, + ) + + # Mark attendance + attendance, created = SessionAttendance.objects.get_or_create( + session=session, student=student, defaults={"status": "present"} + ) + + if not created: + # If attendance record already exists, update its status to present + attendance.status = "present" + attendance.save() + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": True, "message": "Attendance updated successfully", "created": created}) + else: + messages.success(request, "Your attendance has been marked.") + return redirect("classroom_attendance", classroom_id=classroom_id) + + except VirtualClassroom.DoesNotExist: + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": False, "message": "Classroom not found"}, status=404) + else: + messages.error(request, "Classroom not found.") + return redirect("virtual_classroom_list") + except Exception as e: + logger.exception("Error updating student attendance: %s", str(e)) + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": False, "message": "An internal error occurred"}, status=500) + else: + messages.error(request, "An internal error occurred.") + return redirect("classroom_attendance", classroom_id=classroom_id) + + +@login_required +@teacher_required +def student_management(request, course_slug, student_id): + """ + View for managing a specific student in a course. + This replaces the modal functionality with a dedicated page. + """ + course = get_object_or_404(Course, slug=course_slug) + student = get_object_or_404(User, id=student_id) + + # Check if user is the course teacher + if request.user != course.teacher: + messages.error(request, "Only the course teacher can manage students!") + return redirect("course_detail", slug=course.slug) + + # Check if student is enrolled in this course + enrollment = get_object_or_404(Enrollment, course=course, student=student) + + # Get sessions for this course + sessions = course.sessions.all().order_by("start_time") + + # Get attendance records + attendance_records = SessionAttendance.objects.filter(student=student, session__course=course).select_related( + "session" + ) + + # Format attendance data for easier access in template + attendance_data = {} + for record in attendance_records: + attendance_data[record.session.id] = {"status": record.status, "notes": record.notes} + + # Get student progress data + progress = CourseProgress.objects.filter(enrollment=enrollment).first() + completed_sessions = [] + if progress: + completed_sessions = progress.completed_sessions.all() + + # Calculate attendance rate + total_sessions = sessions.count() + attended_sessions = SessionAttendance.objects.filter( + student=student, session__course=course, status__in=["present", "late"] + ).count() + + attendance_rate = 0 + if total_sessions > 0: + attendance_rate = int((attended_sessions / total_sessions) * 100) + + # Get badges earned by this student + user_badges = student.badges.all() + + context = { + "course": course, + "student": student, + "enrollment": enrollment, + "sessions": sessions, + "attendance_data": attendance_data, + "attendance_rate": attendance_rate, + "progress": progress, + "completed_sessions": completed_sessions, + "badges": user_badges, + } + + return render(request, "courses/student_management.html", context) + + +@login_required +@teacher_required +def update_student_progress(request, enrollment_id): + """ + View for updating a student's progress in a course. + """ + enrollment = get_object_or_404(Enrollment, id=enrollment_id) + course = enrollment.course + + # Check if user is the course teacher + if request.user != course.teacher: + messages.error(request, "Only the course teacher can update student progress!") + return redirect("course_detail", slug=course.slug) + + if request.method == "POST": + grade = request.POST.get("grade") + status = request.POST.get("status") + comments = request.POST.get("comments", "") + + # Update enrollment + enrollment.grade = grade + enrollment.status = status + enrollment.notes = comments + enrollment.last_grade_update = timezone.now() + enrollment.save() + + messages.success(request, f"Progress for {enrollment.student.username} updated successfully!") + return redirect("student_management", course_slug=course.slug, student_id=enrollment.student.id) + + # If not POST, redirect back to student management + return redirect("student_management", course_slug=course.slug, student_id=enrollment.student.id) + + +@login_required +@teacher_required +def update_teacher_notes(request, enrollment_id): + """ + View for updating teacher's private notes for a student. + """ + enrollment = get_object_or_404(Enrollment, id=enrollment_id) + course = enrollment.course + + # Check if user is the course teacher + if request.user != course.teacher: + messages.error(request, "Only the course teacher can update notes!") + return redirect("course_detail", slug=course.slug) + + if request.method == "POST": + notes = request.POST.get("teacher_notes", "") + + # If notes have changed, create a new note history entry + if enrollment.teacher_notes != notes and notes.strip(): + NoteHistory.objects.create(enrollment=enrollment, content=notes, created_by=request.user) + + # Update enrollment + enrollment.teacher_notes = notes + enrollment.save() + + messages.success(request, f"Notes for {enrollment.student.username} updated successfully!") + + return redirect("student_management", course_slug=course.slug, student_id=enrollment.student.id) + + +@login_required +@teacher_required +@require_POST +def update_session_attendance(request): + """Update student attendance for a specific session.""" + try: + # Get form data + session_id = request.POST.get("session_id") + student_id = request.POST.get("student_id") + status = request.POST.get("status") + notes = request.POST.get("notes", "") + + if not session_id or not student_id or not status: + return JsonResponse({"success": False, "message": "Missing required data"}, status=400) + + # Get objects + session = get_object_or_404(Session, id=session_id) + student = get_object_or_404(User, id=student_id) + + # Check if the current user is the teacher for this session's course + if session.course and session.course.teacher != request.user: + return JsonResponse( + {"success": False, "message": "Only the course teacher can update attendance"}, status=403 + ) + + # Update or create attendance record + attendance, created = SessionAttendance.objects.get_or_create( + session=session, student=student, defaults={"status": status, "notes": notes} + ) + + if not created: + attendance.status = status + attendance.notes = notes + attendance.save() + + return JsonResponse({"success": True, "message": "Attendance updated successfully", "created": created}) + + except Exception as e: + # Log the detailed exception for debugging + logger.exception("Error in update_session_attendance: %s", str(e)) + return JsonResponse({"success": False, "message": "An internal error occurred"}, status=500) + + +@login_required +@teacher_required +@require_POST +def award_badge(request): + """ + AJAX view for awarding badges to students. + """ + if not request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": False, "message": "Invalid request"}, status=400) + + student_id = request.POST.get("student_id") + badge_type = request.POST.get("badge_type") + course_slug = request.POST.get("course_slug") + + if not all([student_id, badge_type, course_slug]): + return JsonResponse({"success": False, "message": "Missing required parameters"}, status=400) + + try: + student = User.objects.get(id=student_id) + course = Course.objects.get(slug=course_slug) + + # Check if user is the course teacher + if request.user != course.teacher: + return JsonResponse( + {"success": False, "message": "Unauthorized: Only the course teacher can award badges"}, status=403 + ) + + # Handle different badge types + badge = None + if badge_type == "perfect_attendance": + badge, created = Badge.objects.get_or_create( + name="Perfect Attendance", + defaults={"description": "Awarded for attending all sessions in a course", "points": 50}, + ) + elif badge_type == "participation": + badge, created = Badge.objects.get_or_create( + name="Outstanding Participation", + defaults={"description": "Awarded for exceptional participation in course discussions", "points": 75}, + ) + elif badge_type == "completion": + badge, created = Badge.objects.get_or_create( + name="Course Completion", + defaults={"description": "Awarded for successfully completing the course", "points": 100}, + ) + else: + return JsonResponse({"success": False, "message": "Invalid badge type"}, status=400) + + # Award the badge to the student + user_badge, created = UserBadge.objects.get_or_create( + user=student, badge=badge, defaults={"awarded_by": request.user, "course": course} + ) + + if not created: + return JsonResponse({"success": False, "message": "Student already has this badge"}, status=400) + + return JsonResponse( + {"success": True, "message": f"Badge '{badge.name}' awarded successfully to {student.username}"} + ) + + except User.DoesNotExist: + return JsonResponse({"success": False, "message": "Student not found"}, status=404) + except Course.DoesNotExist: + return JsonResponse({"success": False, "message": "Course not found"}, status=404) + except Exception as e: + logger.exception("Error awarding badge: %s", str(e)) + return JsonResponse({"success": False, "message": "An internal error occurred"}, status=500) + + +def notification_preferences(request): + """ + Display and update the notification preferences for the logged-in user. + """ + # Get (or create) the user's notification preferences. + preference, created = NotificationPreference.objects.get_or_create(user=request.user) + + if request.method == "POST": + form = NotificationPreferencesForm(request.POST, instance=preference) + if form.is_valid(): + form.save() + messages.success(request, "Your notification preferences have been updated.") + # Redirect to the profile page after saving + return redirect("profile") + else: + messages.error(request, "There was an error updating your preferences.") + else: + form = NotificationPreferencesForm(instance=preference) + + return render(request, "account/notification_preferences.html", {"form": form}) + + +@login_required +def invite_to_study_group(request, group_id): + """Invite a user to a study group.""" + group = get_object_or_404(StudyGroup, id=group_id) + + # Only allow invitations from current group members. + if request.user not in group.members.all(): + messages.error(request, "You must be a member of the group to invite others.") + return redirect("study_group_detail", group_id=group.id) + + if request.method == "POST": + email_or_username = request.POST.get("email_or_username") + # Search by email or username. + recipient = User.objects.filter(Q(email=email_or_username) | Q(username=email_or_username)).first() + if not recipient: + messages.error(request, f"No user found with email or username: {email_or_username}") + return redirect("study_group_detail", group_id=group.id) + + # Prevent duplicate invitations or inviting existing members. + if recipient in group.members.all(): + messages.warning(request, f"{recipient.username} is already a member of this group.") + return redirect("study_group_detail", group_id=group.id) + + if StudyGroupInvite.objects.filter(group=group, recipient=recipient, status="pending").exists(): + messages.warning(request, f"An invitation has already been sent to {recipient.username}.") + return redirect("study_group_detail", group_id=group.id) + + if group.is_full(): + messages.error(request, "The study group is full. No new members can be added.") + return redirect("study_group_detail", group_id=group.id) + + # Create a notification for the recipient. + notification_url = request.build_absolute_uri(reverse("user_invitations")) + notification_text = ( + f"{request.user.username} has invited you to join the study group: {group.name}. " + f"View invitations here: {notification_url}" + ) + Notification.objects.create( + user=recipient, title="Study Group Invitation", message=notification_text, notification_type="info" + ) + + messages.success(request, f"Invitation sent to {recipient.username}.") + return redirect("study_group_detail", group_id=group.id) + + return redirect("study_group_detail", group_id=group.id) + + +@login_required +def user_invitations(request): + """Display pending study group invitations for the user.""" + invitations = StudyGroupInvite.objects.filter(recipient=request.user, status="pending").select_related( + "group", "sender" + ) + return render(request, "web/study/invitations.html", {"invitations": invitations}) + + +@login_required +def respond_to_invitation(request, invite_id): + """Accept or decline a study group invitation.""" + invite = get_object_or_404(StudyGroupInvite, id=invite_id, recipient=request.user) + if request.method == "POST": + response = request.POST.get("response") + if response == "accept": + if invite.group.is_full(): + messages.error(request, "The study group is full. Cannot join.") + return redirect("user_invitations") + invite.accept() + study_group_url = request.build_absolute_uri(reverse("study_group_detail", args=[invite.group.id])) + notification_text = f"{request.user.username} has accepted your invitation to join {invite.group.name}.\ + View group details here: {study_group_url}" + Notification.objects.create( + user=invite.sender, title="Invitation Accepted", message=notification_text, notification_type="success" + ) + messages.success(request, f"You have joined {invite.group.name}.") + return redirect("user_invitations") + elif response == "decline": + invite.decline() + study_group_url = request.build_absolute_uri(reverse("study_group_detail", args=[invite.group.id])) + notification_text = f"{request.user.username} has declined your invitation to join {invite.group.name}.\ + View group details here: {study_group_url}" + Notification.objects.create( + user=invite.sender, title="Invitation Declined", message=notification_text, notification_type="warning" + ) + messages.info(request, f"You have declined the invitation to {invite.group.name}.") + return redirect("user_invitations") + + return redirect("user_invitations") + + +@login_required +def create_study_group(request): + if request.method == "POST": + form = StudyGroupForm(request.POST) + if form.is_valid(): + study_group = form.save(commit=False) + study_group.creator = request.user + study_group.save() + # Automatically add the creator as a member + study_group.members.add(request.user) + messages.success(request, "Study group created successfully!") + return redirect("study_group_detail", group_id=study_group.id) + else: + form = StudyGroupForm() + return render(request, "web/study/create_group.html", {"form": form}) + + +def features_page(request): + """View to display the features page.""" + return render(request, "features.html") + + +@require_POST +@csrf_protect +def feature_vote(request): + """API endpoint to handle feature voting.""" + feature_id = request.POST.get("feature_id") + vote_type = request.POST.get("vote") + + if not feature_id or vote_type not in ["up", "down"]: + return JsonResponse({"status": "error", "message": "Invalid parameters"}, status=400) + + # Store IP for anonymous users + ip_address = None + if not request.user.is_authenticated: + ip_address = ( + request.META.get("REMOTE_ADDR") or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + ) + if not ip_address: + return JsonResponse({"status": "error", "message": "Could not identify user"}, status=400) + + # Process the vote + try: + # Use transaction to prevent race conditions during vote operations + with transaction.atomic(): + # Check for existing vote + existing_vote = None + if request.user.is_authenticated: + existing_vote = FeatureVote.objects.filter(feature_id=feature_id, user=request.user).first() + elif ip_address: + existing_vote = FeatureVote.objects.filter( + feature_id=feature_id, ip_address=ip_address, user__isnull=True + ).first() + + # Handle the vote logic + status_message = "Vote recorded" + if existing_vote: + if existing_vote.vote == vote_type: + status_message = "You've already cast this vote" + else: + # Change vote type if user changed their mind + existing_vote.vote = vote_type + existing_vote.save() + status_message = "Vote changed" + else: + # Create new vote + new_vote = FeatureVote(feature_id=feature_id, vote=vote_type) + + if request.user.is_authenticated: + new_vote.user = request.user + else: + new_vote.ip_address = ip_address + + new_vote.save() + + # Get updated counts within the transaction to ensure consistency + up_count = FeatureVote.objects.filter(feature_id=feature_id, vote="up").count() + down_count = FeatureVote.objects.filter(feature_id=feature_id, vote="down").count() + + # Calculate percentage for visualization + total_votes = up_count + down_count + up_percentage = round((up_count / total_votes) * 100) if total_votes > 0 else 0 + down_percentage = round((down_count / total_votes) * 100) if total_votes > 0 else 0 + + return JsonResponse( + { + "status": "success", + "message": status_message, + "up_count": up_count, + "down_count": down_count, + "total_votes": total_votes, + "up_percentage": up_percentage, + "down_percentage": down_percentage, + } + ) + + except Exception as e: + logger.exception("Error processing vote: %s", str(e)) + return JsonResponse({"status": "error", "message": "An internal error occurred"}, status=500) + + +@require_GET +def feature_vote_count(request): + """Get vote counts for one or more features.""" + feature_ids = request.GET.get("feature_ids", "").split(",") + if not feature_ids or not feature_ids[0]: + return JsonResponse({"error": "No feature IDs provided"}, status=400) + + result = {} + # Get all up and down votes in a single query each for better performance + up_votes = ( + FeatureVote.objects.filter(feature_id__in=feature_ids, vote="up") + .values("feature_id") + .annotate(count=Count("id")) + ) + down_votes = ( + FeatureVote.objects.filter(feature_id__in=feature_ids, vote="down") + .values("feature_id") + .annotate(count=Count("id")) + ) + + # Create lookup dictionaries for efficient access + up_votes_dict = {str(vote["feature_id"]): vote["count"] for vote in up_votes} + down_votes_dict = {str(vote["feature_id"]): vote["count"] for vote in down_votes} + + # Check user's votes if authenticated + user_votes = {} + if request.user.is_authenticated: + user_vote_objects = FeatureVote.objects.filter(feature_id__in=feature_ids, user=request.user) + for vote in user_vote_objects: + user_votes[str(vote.feature_id)] = vote.vote + elif request.META.get("REMOTE_ADDR"): + # Get IP address for anonymous users + ip_address = ( + request.META.get("REMOTE_ADDR") or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + ) + if ip_address: + anon_vote_objects = FeatureVote.objects.filter( + feature_id__in=feature_ids, ip_address=ip_address, user__isnull=True + ) + for vote in anon_vote_objects: + user_votes[str(vote.feature_id)] = vote.vote + + for feature_id in feature_ids: + up_count = up_votes_dict.get(feature_id, 0) + down_count = down_votes_dict.get(feature_id, 0) + total_votes = up_count + down_count + + # Calculate percentages + up_percentage = (up_count / total_votes * 100) if total_votes > 0 else 0 + down_percentage = (down_count / total_votes * 100) if total_votes > 0 else 0 + + result[feature_id] = { + "up_count": up_count, + "down_count": down_count, + "total_votes": total_votes, + "up_percentage": round(up_percentage, 1), + "down_percentage": round(down_percentage, 1), + "user_vote": user_votes.get(feature_id, None), + } + + return JsonResponse(result) + + +@login_required +def progress_visualization(request): + """Generate and render progress visualization statistics for a student's enrolled courses.""" + user = request.user + + # Create a unique cache key based on user ID + cache_key = f"user_progress_{user.id}" + context = cache.get(cache_key) + + if not context: + # Cache miss - calculate all data + enrollments = Enrollment.objects.filter(student=user) + course_stats = calculate_course_stats(enrollments) + attendance_stats = calculate_attendance_stats(user, enrollments) + learning_activity = calculate_learning_activity(user, enrollments) + completion_pace = calculate_completion_pace(enrollments) + chart_data = prepare_chart_data(enrollments) + + # Combine all stats into a single context dictionary + context = {**course_stats, **attendance_stats, **learning_activity, **completion_pace, **chart_data} + # Cache the results (no expiration, we'll invalidate manually via signals) + cache.set(cache_key, context, timeout=None) # None means no expiration + + return render(request, "courses/progress_visualization.html", context) + + +def calculate_course_stats(enrollments): + """Calculate statistics on the user's course progress.""" + total_courses = enrollments.count() + courses_completed = enrollments.filter(status="completed").count() + topics_mastered = sum(e.progress.completed_sessions.count() for e in enrollments if hasattr(e, "progress")) + + return { + "total_courses": total_courses, + "courses_completed": courses_completed, + "courses_completed_percentage": round((courses_completed / total_courses) * 100) if total_courses else 0, + "topics_mastered": topics_mastered, + } + + +def calculate_attendance_stats(user, enrollments): + """Calculate the user's attendance statistics.""" + all_attendances = SessionAttendance.objects.filter( + student=user, session__course__in=[e.course for e in enrollments] + ) + total_attendance_count = all_attendances.count() + present_attendance_count = all_attendances.filter(status__in=["present", "late"]).count() + + return { + "average_attendance": ( + round((present_attendance_count / total_attendance_count) * 100) if total_attendance_count else 0 + ) + } + + +def calculate_learning_activity(user, enrollments): + """Calculate learning activity metrics like active days, streaks, and learning hours.""" + all_completed_sessions = [ + s for s in get_all_completed_sessions(enrollments) if s.start_time and s.end_time and s.end_time > s.start_time + ] + now = timezone.now() + + # Find the most active day of the week + most_active_day = Counter(session.start_time.strftime("%A") for session in all_completed_sessions).most_common(1) + # Find the most recent session date + last_session_date = ( + max(all_completed_sessions, key=lambda s: s.start_time).start_time.strftime("%b %d, %Y") + if all_completed_sessions + else "N/A" + ) + + streak, _ = LearningStreak.objects.get_or_create(user=user) + current_streak = streak.current_streak + + total_learning_hours = round( + sum((s.end_time - s.start_time).total_seconds() / 3600 for s in all_completed_sessions), 1 + ) + + # Calculate the number of weeks since the first session, minimum 1 week + weeks_since_first_session = ( + max(1, (now - min(all_completed_sessions, key=lambda s: s.start_time).start_time).days / 7) + if all_completed_sessions + else 1 + ) + avg_sessions_per_week = round(len(all_completed_sessions) / weeks_since_first_session, 1) + + return { + # Extract the day name from the most common day tuple, or default to "N/A" + "most_active_day": most_active_day[0][0] if most_active_day else "N/A", + "last_session_date": last_session_date, + "current_streak": current_streak, + "total_learning_hours": total_learning_hours, + "avg_sessions_per_week": avg_sessions_per_week, + } + + +def calculate_completion_pace(enrollments): + """Calculate the average completion pace for completed courses.""" + completed_enrollments = enrollments.filter(status="completed") + if not completed_enrollments.exists(): + return {"completion_pace": "N/A"} + + total_days = sum( + (e.completion_date - e.enrollment_date).days + for e in completed_enrollments + if e.completion_date and e.enrollment_date + ) + avg_days_to_complete = total_days / completed_enrollments.count() if completed_enrollments.count() > 0 else 0 + + return {"completion_pace": f"{avg_days_to_complete:.0f} days/course"} + + +def get_all_completed_sessions(enrollments): + """Retrieve all completed sessions for a user's enrollments.""" + return [s for e in enrollments if hasattr(e, "progress") for s in e.progress.completed_sessions.all()] + + +def prepare_chart_data(enrollments): + """Prepare data for visualizing user progress in charts.""" + colors = ["255,99,132", "54,162,235", "255,206,86", "75,192,192", "153,102,255"] + + courses = [] + progress_dates, sessions_completed = [], [] + + for i, e in enumerate(enrollments): + color = colors[i % len(colors)] + # Basic course data with progress information + course_data = { + "title": e.course.title, + "color": color, + "progress": getattr(e.progress, "completion_percentage", 0), + "sessions_completed": e.progress.completed_sessions.count() if hasattr(e, "progress") else 0, + "total_sessions": e.course.sessions.count(), + } + + # Add time series data for courses with completed sessions + if hasattr(e, "progress") and e.progress.completed_sessions.exists(): + # Find the most recent active session date + last_session = max(e.progress.completed_sessions.all(), key=lambda s: s.start_time) + course_data["last_active"] = last_session.start_time.strftime("%b %d, %Y") + # Generate time series data for progress visualization + time_data = prepare_time_series_data(e, course_data["total_sessions"]) + course_data.update(time_data) + progress_dates.append(time_data["dates"]) + sessions_completed.append(time_data["sessions_points"]) + else: + # Default values for courses without progress + course_data.update({"last_active": "Not started", "progress_over_time": []}) + progress_dates.append([]) + sessions_completed.append([]) + + courses.append(course_data) + + return { + "courses": courses, + # Create a sorted list of all unique dates by flattening and deduplicating + "progress_dates": json.dumps(sorted(set(date for dates in progress_dates for date in dates))), + "sessions_completed": json.dumps(sessions_completed), + "courses_json": json.dumps(courses), + } + + +def prepare_time_series_data(enrollment, total_sessions): + """Generate time series data for progress visualization.""" + completed_sessions = ( + sorted(enrollment.progress.completed_sessions.all(), key=lambda s: s.start_time) + if hasattr(enrollment, "progress") + else [] + ) + + return { + # Calculate progress percentage for each completed session + "progress_over_time": [ + round(((idx + 1) / total_sessions) * 100, 1) if total_sessions else 0 + for idx, _ in enumerate(completed_sessions) + ], + # Create sequential session numbers + "sessions_points": list(range(1, len(completed_sessions) + 1)), + # Format session dates consistently + "dates": [s.start_time.strftime("%Y-%m-%d") for s in completed_sessions], + } + + +# map views + + +@login_required +def classes_map(request): + """View for displaying classes near the user.""" + now = timezone.now() + sessions = ( + Session.objects.filter(Q(start_time__gte=now) | Q(start_time__lte=now, end_time__gte=now)) + .filter(is_virtual=False, location__isnull=False) + .exclude(location="") + .order_by("start_time") + .select_related("course", "course__teacher") + ) + # Get filter parameters + course_id = request.GET.get("course") + teaching_style = request.GET.get("teaching_style") + # Apply filters + if course_id: + sessions = sessions.filter(course_id=course_id, status="published") + if teaching_style: + sessions = sessions.filter(teaching_style=teaching_style) + # Fetch only necessary course fields + courses = Course.objects.only("id", "title").order_by("title") + age_groups = Course._meta.get_field("level").choices + teaching_styles = list(set(Session.objects.values_list("teaching_style", flat=True))) + context = {"sessions": sessions, "courses": courses, "age_groups": age_groups, "teaching_style": teaching_styles} + return render(request, "web/classes_map.html", context) + + +@login_required +def map_data_api(request): + """API to return all live and ongoing class data in JSON format.""" + now = timezone.now() + sessions = ( + Session.objects.filter( + Q(start_time__gte=now) | Q(start_time__lte=now, end_time__gte=now) # Future or Live classes + ) + .filter(Q(is_virtual=False) & ~Q(location="")) + .select_related("course", "course__teacher") + ) + + course_id = request.GET.get("course") + age_group = request.GET.get("age_group") + + if course_id: + sessions = sessions.filter(course__id=course_id, status="published") + if age_group: + sessions = sessions.filter(course__level=age_group) + + logger.debug(f"API call with filters: course={course_id}, age={age_group}") + + map_data = [] + sessions_to_update = [] + # Limit geocoding to a reasonable number per request + MAX_GEOCODING_PER_REQUEST = 5 + geocoding_count = 0 + geocoding_errors = 0 + coordinate_errors = 0 + for session in sessions: + if not session.latitude or not session.longitude: + if geocoding_count >= MAX_GEOCODING_PER_REQUEST: + logger.warning(f"Geocoding limit reached ({MAX_GEOCODING_PER_REQUEST}). Skipping session {session.id}") + continue + geocoding_count += 1 + logger.info(f"Geocoding session {session.id} with location: {session.location}") + lat, lng = geocode_address(session.location) + if lat is not None and lng is not None: + session.latitude = lat + session.longitude = lng + sessions_to_update.append(session) + else: + geocoding_errors += 1 + logger.warning(f"Skipping session {session.id} due to failed geocoding") + continue + + try: + lat = float(session.latitude) + lng = float(session.longitude) + map_data.append( + { + "id": session.id, + "title": session.title, + "course_title": session.course.title, + "teacher": session.course.teacher.get_full_name() or session.course.teacher.username, + "start_time": session.start_time.isoformat(), + "end_time": session.end_time.isoformat(), + "location": session.location, + "lat": lat, + "lng": lng, + "price": str(session.price or session.course.price), + "url": session.get_absolute_url(), + "course": session.course.title, + "level": session.course.get_level_display(), + "is_virtual": session.is_virtual, + } + ) + except (ValueError, TypeError): + coordinate_errors += 1 + logger.warning( + f"Skipping session {session.id} due to invalid coordinates: " + f"lat={session.latitude}, lng={session.longitude}" + ) + continue + + if sessions_to_update: + logger.info(f"Batch updating coordinates for {len(sessions_to_update)} sessions") + Session.objects.bulk_update(sessions_to_update, ["latitude", "longitude"]) # Batch update + + # Log summary of issues + if geocoding_errors > 0 or coordinate_errors > 0: + logger.warning(f"Map data issues: {geocoding_errors} geocoding errors, {coordinate_errors} coordinate errors") + + logger.info(f"Found {len(map_data)} sessions with valid coordinates") + return JsonResponse({"sessions": map_data}) + + +GITHUB_REPO = "alphaonelabs/alphaonelabs-education-website" +GITHUB_API_BASE = "https://api.github.com" + +logger = logging.getLogger(__name__) + + +def github_api_request(endpoint, params=None, headers=None): + """ + Make a GitHub API request with consistent error handling and timeout. + Returns JSON response on success, empty dict on failure. + """ + try: + response = requests.get(endpoint, params=params, headers=headers, timeout=10) + if response.status_code == 200: + return response.json() + else: + logger.error(f"Failed API request to {endpoint}: {response.status_code} - {response.text}") + return {} + except requests.RequestException as e: + logger.error(f"Request error for {endpoint}: {e}") + return {} + + +def get_user_contribution_metrics(username, token): + """ + Use the GitHub GraphQL API to fetch all of the user's merged PRs (across all repos), + then filter by the target repo, implementing pagination to ensure complete data. + """ + graphql_endpoint = "https://api.github.com/graphql" + headers = {"Authorization": f"Bearer {token}"} + + query = """ + query($username: String!, $after: String) { + user(login: $username) { + pullRequests( + first: 100 + after: $after + states: MERGED + orderBy: { field: CREATED_AT, direction: DESC } + ) { + totalCount + pageInfo { + endCursor + hasNextPage + } + nodes { + additions + deletions + createdAt + repository { + nameWithOwner + } + } + } + } + } + """ + + all_filtered_prs = [] + after_cursor = None + + while True: + variables = {"username": username, "after": after_cursor} + try: + response = requests.post( + graphql_endpoint, json={"query": query, "variables": variables}, headers=headers, timeout=15 + ) + if response.status_code == 200: + data = response.json() + if data.get("data") and data["data"].get("user"): + pr_data = data["data"]["user"]["pullRequests"] + prs = pr_data["nodes"] + + # Filter PRs to the target repository (case insensitive) + target_repo = GITHUB_REPO.lower() + filtered_prs = [pr for pr in prs if pr["repository"]["nameWithOwner"].lower() == target_repo] + all_filtered_prs.extend(filtered_prs) + + # Check if there are more pages + if pr_data["pageInfo"]["hasNextPage"]: + after_cursor = pr_data["pageInfo"]["endCursor"] + else: + break + else: + logger.error("No user or PR data found in GraphQL response.") + break + else: + logger.error(f"GraphQL error: {response.status_code} - {response.text}") + break + except Exception as e: + logger.error(f"GraphQL request error: {e}") + break + + # Prepare final result structure + final_result = { + "data": {"user": {"pullRequests": {"totalCount": len(all_filtered_prs), "nodes": all_filtered_prs}}} + } + return final_result + + +def contributor_detail_view(request, username): + """ + View to display detailed information about a specific GitHub contributor. + Only accessible to staff members. + """ + cache_key = f"github_contributor_{username}" + cached_data = cache.get(cache_key) + if cached_data: + return render(request, "web/contributor_detail.html", cached_data) + + token = os.environ.get("GITHUB_TOKEN") + headers = {"Authorization": f"token {token}"} if token else {} + + # Initialize variables to store contributor data + user_data = {} + prs_created = 0 + prs_merged = 0 + issues_created = 0 + pr_reviews = 0 + pr_comments = 0 + issue_comments = 0 + lines_added = 0 + lines_deleted = 0 + first_contribution_date = "N/A" + issue_assignments = 0 + + user_endpoint = f"{GITHUB_API_BASE}/users/{username}" + user_data = github_api_request(user_endpoint, headers=headers) + if not user_data: + logger.error("User profile data could not be retrieved.") + + # Pull requests created + prs_created_json = github_api_request( + f"{GITHUB_API_BASE}/search/issues", + params={"q": f"author:{username} type:pr repo:{GITHUB_REPO}"}, + headers=headers, + ) + prs_created = prs_created_json.get("total_count", 0) + + # Pull requests merged + prs_merged_json = github_api_request( + f"{GITHUB_API_BASE}/search/issues", + params={"q": f"author:{username} type:pr repo:{GITHUB_REPO} is:merged"}, + headers=headers, + ) + prs_merged = prs_merged_json.get("total_count", 0) + + # Issues created + issues_json = github_api_request( + f"{GITHUB_API_BASE}/search/issues", + params={"q": f"author:{username} type:issue repo:{GITHUB_REPO}"}, + headers=headers, + ) + issues_created = issues_json.get("total_count", 0) + + # Pull request reviews + reviews_json = github_api_request( + f"{GITHUB_API_BASE}/search/issues", + params={"q": f"reviewer:{username} type:pr repo:{GITHUB_REPO}"}, + headers=headers, + ) + pr_reviews = reviews_json.get("total_count", 0) + + # Pull requests with comments + pr_comments_json = github_api_request( + f"{GITHUB_API_BASE}/search/issues", + params={"q": f"commenter:{username} type:pr repo:{GITHUB_REPO}"}, + headers=headers, + ) + pr_comments = pr_comments_json.get("total_count", 0) + + # Issues with comments + issue_comments_json = github_api_request( + f"{GITHUB_API_BASE}/search/issues", + params={"q": f"commenter:{username} type:issue repo:{GITHUB_REPO}"}, + headers=headers, + ) + issue_comments = issue_comments_json.get("total_count", 0) + + # Oldest PR creation date + prs_oldest = github_api_request( + f"{GITHUB_API_BASE}/search/issues", + params={ + "q": f"author:{username} type:pr repo:{GITHUB_REPO}", + "sort": "created", + "order": "asc", + "per_page": 1, + }, + headers=headers, + ) + if prs_oldest.get("total_count", 0) > 0: + first_contribution_date = prs_oldest["items"][0].get("created_at", "N/A") + + # Issue assignments + issue_assignments_json = github_api_request( + f"{GITHUB_API_BASE}/search/issues", + params={"q": f"assignee:{username} type:issue repo:{GITHUB_REPO}"}, + headers=headers, + ) + issue_assignments = issue_assignments_json.get("total_count", 0) + metrics = get_user_contribution_metrics(username, token) + pr_data = metrics.get("data", {}).get("user", {}).get("pullRequests", {}).get("nodes", []) + for pr in pr_data: + lines_added += pr.get("additions", 0) + lines_deleted += pr.get("deletions", 0) + + # Update user_data with additional metrics + user_data.update( + { + "reactions_received": user_data.get("reactions_received", 0), + "mentorship_score": user_data.get("mentorship_score", 0), + "collaboration_score": user_data.get("collaboration_score", 0), + "issue_assignments": issue_assignments, + } + ) + + # Prepare context for the template + context = { + "user": user_data, + "prs_created": prs_created, + "prs_merged": prs_merged, + "pr_reviews": pr_reviews, + "issues_created": issues_created, + "issue_comments": issue_comments, + "pr_comments": pr_comments, + "lines_added": lines_added, + "lines_deleted": lines_deleted, + "first_contribution_date": first_contribution_date, + "chart_data": { + "prs_created": prs_created, + "prs_merged": prs_merged, + "pr_reviews": pr_reviews, + "issues_created": issues_created, + "issue_assignments": issue_assignments, + "pr_comments": pr_comments, + "issue_comments": issue_comments, + "lines_added": lines_added, + "lines_deleted": lines_deleted, + "first_contribution_date": (first_contribution_date if first_contribution_date != "N/A" else "N/A"), + }, + } + + # Cache for 1 hour + cache.set(cache_key, context, 3600) + return render(request, "web/contributor_detail.html", context) + + +@login_required +def all_study_groups(request): + """Display all study groups across courses.""" + # Get all study groups + groups = StudyGroup.objects.all().order_by("-created_at") + + # Group study groups by course + courses_with_groups = {} + for group in groups: + if group.course not in courses_with_groups: + courses_with_groups[group.course] = [] + courses_with_groups[group.course].append(group) + + # Handle creating a new study group + if request.method == "POST": + course_id = request.POST.get("course") + name = request.POST.get("name") + description = request.POST.get("description") + max_members = request.POST.get("max_members", 10) + is_private = request.POST.get("is_private", False) == "on" # Convert checkbox to boolean + + try: + # Validate the input + if not course_id or not name or not description: + raise ValueError("All fields are required") + + # Get the course + course = Course.objects.get(id=course_id) + + # Create the group + group = StudyGroup.objects.create( + course=course, + creator=request.user, + name=name, + description=description, + max_members=int(max_members), + is_private=is_private, + ) + + # Add the creator as a member + group.members.add(request.user) + + messages.success(request, "Study group created successfully!") + return redirect("study_group_detail", group_id=group.id) + except Course.DoesNotExist: + messages.error(request, "Course not found.") + except ValueError as e: + messages.error(request, str(e)) + except Exception as e: + messages.error(request, f"Error creating study group: {str(e)}") + + # Get user's enrollments for the create group form + enrollments = request.user.enrollments.filter(status="approved").select_related("course") + enrolled_courses = [enrollment.course for enrollment in enrollments] + + return render( + request, + "web/study/all_groups.html", + { + "courses_with_groups": courses_with_groups, + "enrolled_courses": enrolled_courses, + }, + ) + + +@login_required +def membership_checkout(request, plan_id: int) -> HttpResponse: + """Display the membership checkout page.""" + plan = get_object_or_404(MembershipPlan, id=plan_id) + + # Default to monthly billing + billing_period = request.GET.get("billing_period", "monthly") + + context = { + "plan": plan, + "billing_period": billing_period, + "stripe_public_key": settings.STRIPE_PUBLISHABLE_KEY, + } + + return render(request, "checkout.html", context) + + +@login_required +def create_membership_subscription(request) -> JsonResponse: + """Create a new membership subscription.""" + if request.method != "POST": + return JsonResponse({"error": "Invalid request method"}, status=400) + + try: + data = json.loads(request.body) + plan_id = data.get("plan_id") + payment_method_id = data.get("payment_method_id") + billing_period = data.get("billing_period", "monthly") + + if not all([plan_id, payment_method_id, billing_period]): + return JsonResponse({"error": "Missing required fields"}, status=400) + + # Create subscription using helper function + result = create_subscription( + user=request.user, + plan_id=plan_id, + payment_method_id=payment_method_id, + billing_period=billing_period, + ) + + if not result["success"]: + return JsonResponse({"error": result["error"]}, status=400) + + # Helper function to extract client_secret + def get_client_secret(subscription): + """Extract client_secret safely from a subscription object.""" + if ( + hasattr(subscription, "latest_invoice") + and subscription.latest_invoice + and hasattr(subscription.latest_invoice, "payment_intent") + ): + return subscription.latest_invoice.payment_intent.client_secret + return None + + return JsonResponse( + { + "subscription": result["subscription"], + "client_secret": get_client_secret(result["subscription"]), + }, + ) + + except json.JSONDecodeError as e: + logger.warning("Invalid JSON in create_membership_subscription: %s", str(e)) + return JsonResponse({"error": "Invalid JSON format"}, status=400) + except stripe.error.CardError as e: + logger.warning("Card error in create_membership_subscription: %s", str(e)) + return JsonResponse({"error": "Card payment failed"}, status=400) + except stripe.error.StripeError as e: + logger.error("Stripe error in create_membership_subscription: %s", str(e)) + return JsonResponse({"error": "Payment processing error"}, status=500) + except KeyError as e: + logger.warning("Missing key in create_membership_subscription: %s", str(e)) + return JsonResponse({"error": "Invalid request data"}, status=400) + except Exception as e: + logger.error("Unexpected error in create_membership_subscription: %s", str(e)) + return JsonResponse({"error": "An internal error occurred"}, status=500) + + +@login_required +def membership_success(request) -> HttpResponse: + """Display the membership success page.""" + try: + membership = request.user.membership + context = { + "membership": membership, + } + return render(request, "membership_success.html", context) + except (AttributeError, ObjectDoesNotExist): + messages.info(request, "You don't have an active membership subscription.") + return redirect("index") + + +@login_required +def membership_settings(request) -> HttpResponse: + """Display the membership settings page.""" + try: + membership = request.user.membership + + # Get Stripe invoices + if membership.stripe_customer_id: + setup_stripe() + invoices = stripe.Invoice.list(customer=membership.stripe_customer_id, limit=12) + else: + invoices = [] + + # Get subscription events + events = MembershipSubscriptionEvent.objects.filter(user=request.user).order_by("-created_at")[:10] + + context = { + "membership": membership, + "invoices": invoices.data if hasattr(invoices, "data") and invoices.data else [], + "events": events, + } + return render(request, "membership_settings.html", context) + except (AttributeError, ObjectDoesNotExist): + return redirect("index") + + +@login_required +def cancel_membership(request) -> HttpResponse: + """Cancel the user's membership subscription.""" + if request.method != "POST": + return redirect("membership_settings") + + try: + result = cancel_subscription(request.user) + + if result["success"]: + messages.success( + request, + "Your subscription has been cancelled and will end at the current billing period.", + ) + else: + messages.error(request, result["error"]) + + except stripe.error.StripeError: + logger.exception("Stripe error in cancel_membership") + messages.error(request, "An internal error occurred") + except ObjectDoesNotExist: + messages.error(request, "No membership found for your account.") + except Exception: + logger.exception("Unexpected error in cancel_membership") + messages.error(request, "An internal error occurred") + + return redirect("membership_settings") + + +@login_required +def reactivate_membership(request) -> HttpResponse: + """Reactivate a cancelled membership subscription.""" + if request.method != "POST": + return redirect("membership_settings") + + try: + result = reactivate_subscription(request.user) + + if result["success"]: + messages.success(request, "Your subscription has been reactivated.") + else: + messages.error(request, result["error"]) + + except stripe.error.StripeError as e: + logger.error("Stripe error in reactivate_membership: %s", str(e)) + messages.error(request, "An internal error occurred") + except ObjectDoesNotExist: + messages.error(request, "No membership found for your account.") + except Exception as e: + logger.error("Unexpected error in reactivate_membership: %s", str(e)) + messages.error(request, "An internal error occurred") + + return redirect("membership_settings") + + +@login_required +def update_payment_method(request) -> HttpResponse: + """Display the update payment method page.""" + try: + membership = request.user.membership + + # Get current payment method + if membership.stripe_customer_id: + setup_stripe() + payment_methods = stripe.PaymentMethod.list(customer=membership.stripe_customer_id, type="card") + current_payment_method = payment_methods.data[0] if payment_methods.data else None + else: + current_payment_method = None + + context = { + "membership": membership, + "current_payment_method": current_payment_method, + "stripe_public_key": settings.STRIPE_PUBLISHABLE_KEY, + } + return render(request, "update_payment_method.html", context) + except (AttributeError, ObjectDoesNotExist): + return redirect("membership_settings") + + +@login_required +def update_payment_method_api(request) -> JsonResponse: + """Update the payment method for a subscription.""" + if request.method != "POST": + return JsonResponse({"error": "Invalid request method"}, status=400) + + try: + data = json.loads(request.body) + payment_method_id = data.get("payment_method_id") + + if not payment_method_id: + return JsonResponse({"error": "Missing payment method ID"}, status=400) + + membership = request.user.membership + + if not membership.stripe_customer_id: + return JsonResponse({"error": "No active subscription found"}, status=400) + + setup_stripe() + + # Attach payment method to customer + stripe.PaymentMethod.attach(payment_method_id, customer=membership.stripe_customer_id) + + # Set as default payment method + stripe.Customer.modify( + membership.stripe_customer_id, + invoice_settings={"default_payment_method": payment_method_id}, + ) + + return JsonResponse({"success": True}) + + except stripe.error.CardError as e: + logger.warning("Card error in update_payment_method_api: %s", str(e)) + return JsonResponse({"error": "Card payment failed"}, status=400) + except stripe.error.InvalidRequestError as e: + logger.warning("Invalid request in update_payment_method_api: %s", str(e)) + return JsonResponse({"error": "Invalid payment method"}, status=400) + except stripe.error.StripeError as e: + logger.error("Stripe error in update_payment_method_api: %s", str(e)) + return JsonResponse({"error": "Payment processing error"}, status=500) + except Exception as e: + logger.error("Unexpected error in update_payment_method_api: %s", str(e)) + return JsonResponse({"error": "An internal error occurred"}, status=500) + + +def social_media_manager_required(user): + """Check if user has social media manager permissions.""" + return user.is_authenticated and (user.is_staff or getattr(user.profile, "is_social_media_manager", False)) + + +@user_passes_test(social_media_manager_required) +def get_twitter_client(): + """Initialize the Tweepy client.""" + auth = tweepy.OAuthHandler(settings.TWITTER_API_KEY, settings.TWITTER_API_SECRET_KEY) + auth.set_access_token(settings.TWITTER_ACCESS_TOKEN, settings.TWITTER_ACCESS_TOKEN_SECRET) + return tweepy.API(auth) + + +@user_passes_test(social_media_manager_required, login_url="/accounts/login/") +def social_media_dashboard(request): + # Fetch all posts that haven't been posted yet + posts = ScheduledPost.objects.filter(posted=False).order_by("-id") + return render(request, "social_media_dashboard.html", {"posts": posts}) + + +@user_passes_test(social_media_manager_required) +def post_to_twitter(request, post_id): + post = get_object_or_404(ScheduledPost, id=post_id) + if request.method == "POST": + client = get_twitter_client() + try: + if post.image: + # Upload the image file from disk + media = client.media_upload(post.image.path) + client.update_status(post.content, media_ids=[media.media_id]) + else: + client.update_status(post.content) + post.posted = True + post.posted_at = timezone.now() + post.save() + except Exception as e: + print(f"Error posting tweet: {e}") + return redirect("social_media_dashboard") + return redirect("social_media_dashboard") + + +@user_passes_test(social_media_manager_required) +def create_scheduled_post(request): + if request.method == "POST": + content = request.POST.get("content") + image = request.FILES.get("image") # Get the uploaded image, if provided. + if not content: + messages.error(request, "Post content cannot be empty.") + return redirect("social_media_dashboard") + ScheduledPost.objects.create( + content=content, image=image, scheduled_time=timezone.now() # This saves the image file. + ) + messages.success(request, "Post created successfully!") + return redirect("social_media_dashboard") + + +@user_passes_test(social_media_manager_required) +def delete_post(request, post_id): + """Delete a scheduled post.""" + post = get_object_or_404(ScheduledPost, id=post_id) + if request.method == "POST": + post.delete() + return redirect("social_media_dashboard") + + +def generate_discount_code(length=8): + return "".join(random.choices(string.ascii_uppercase + string.digits, k=length)) + + +@login_required +def apply_discount_via_referrer(request) -> HttpResponse: + """Apply a discount code when a user shares a course on Twitter. + + Args: + request: The HTTP request object + + Returns: + HttpResponse: A redirect to the profile page or an error response + """ + if request.method == "GET": + course_id = request.GET.get("course_id") + if not course_id: + return HttpResponseBadRequest("Course ID not provided.") + + course = get_object_or_404(Course, id=course_id) + + # Validate that the referrer is from Twitter + referrer = request.META.get("HTTP_REFERER", "").lower() + valid_twitter_domains = ["twitter.com", "t.co", "x.com"] + is_valid_referrer = any(domain in referrer for domain in valid_twitter_domains) + + # Skip the referrer check in development environment for testing + if settings.DEBUG: + logger.warning("Bypassing Twitter referrer check in DEBUG mode") + elif not is_valid_referrer: + messages.error(request, "You must click the link from Twitter to claim your discount.") + return redirect("profile") + # Create or retrieve an existing discount record. + discount = Discount.objects.filter(user=request.user, course=course, used=False).first() + if discount is None: + discount = Discount.objects.create( + user=request.user, + course=course, + code=generate_discount_code(), + discount_percentage=5.00, + valid_from=timezone.now(), + valid_until=default_valid_until(), + ) + + messages.success(request, "Thank you for sharing! Your discount code is now available in your profile.") + # Redirect user to their profile where discount codes are rendered. + return redirect("profile") + else: + return HttpResponseBadRequest("Invalid request method.") + + +def users_list(request: HttpRequest) -> HttpResponse: + """ + Display a list of users who have their profile set to public, + ordered by most recent updates. + """ + profiles = Profile.objects.filter(is_profile_public=True).select_related("user").order_by("-updated_at") + + # Add statistics for each user to create fun scorecards + for profile in profiles: + if profile.is_teacher: + # Teacher stats + courses = Course.objects.filter(teacher=profile.user).prefetch_related("enrollments", "reviews") + profile.total_courses = courses.count() + profile.total_students = sum(course.enrollments.filter(status="approved").count() for course in courses) + # Get average rating across all courses + course_ratings = [course.average_rating for course in courses if course.average_rating > 0] + profile.avg_rating = round(sum(course_ratings) / len(course_ratings), 1) if course_ratings else 0 + else: + # Student stats + enrollments = Enrollment.objects.filter(student=profile.user).select_related("course") + profile.total_courses = enrollments.count() + completed_enrollments = enrollments.filter(status="completed") + profile.total_completed = completed_enrollments.count() + + # Calculate average progress across all courses + total_progress = 0 + progress_count = 0 + enrollment_ids = [e.id for e in enrollments] + existing_progresses = { + p.enrollment_id: p for p in CourseProgress.objects.filter(enrollment_id__in=enrollment_ids) + } + + for enrollment in enrollments: + progress = existing_progresses.get(enrollment.id) + if not progress: + progress = CourseProgress.objects.create(enrollment=enrollment) + total_progress += progress.completion_percentage + progress_count += 1 + profile.avg_progress = round(total_progress / progress_count) if progress_count > 0 else 0 + + # Add achievements count + profile.achievements_count = Achievement.objects.filter(student=profile.user).count() + + # Pagination: 12 profiles per page + paginator = Paginator(profiles, 12) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + context = { + "page_obj": page_obj, + } + + return render(request, "users_list.html", context) + + +@login_required +def topic_vote(request, pk): + """Handle voting on a topic.""" + if request.method != "POST": + return JsonResponse({"error": "Only POST method allowed"}, status=405) + + try: + topic = ForumTopic.objects.get(pk=pk) + vote_type = request.POST.get("vote_type") + + if vote_type not in ["up", "down"]: + # For form submissions, redirect back with an error message if needed + messages.error(request, "Invalid vote type") + return redirect("topic_vote", pk=topic.id) + + # Check if user already voted on this topic + vote, created = ForumVote.objects.get_or_create( + user=request.user, topic=topic, defaults={"vote_type": vote_type} + ) + + if not created: + # User already voted, check if they're changing their vote + if vote.vote_type == vote_type: + # Same vote type, so remove the vote + vote.delete() + else: + # Different vote type, so update the vote + vote.vote_type = vote_type + vote.save() + + # After processing the vote, redirect back to the topic page + return redirect("forum_topic", category_slug=topic.category.slug, topic_id=topic.id) + + except ForumTopic.DoesNotExist: + # Handle case when topic doesn't exist + messages.error(request, "Topic not found") + return redirect("forum_categories") + + +@login_required +def reply_vote(request, pk): + """Handle voting on a reply.""" + if request.method != "POST": + return JsonResponse({"error": "Only POST method allowed"}, status=405) + + try: + reply = ForumReply.objects.get(pk=pk) + vote_type = request.POST.get("vote_type") + + if vote_type not in ["up", "down"]: + messages.error(request, "Invalid vote type") + return redirect("forum_topic", category_slug=reply.topic.category.slug, topic_id=reply.topic.id) + + # Check if user already voted on this reply + vote, created = ForumVote.objects.get_or_create( + user=request.user, reply=reply, defaults={"vote_type": vote_type} + ) + + if not created: + # User already voted, check if they're changing their vote + if vote.vote_type == vote_type: + # Same vote type, so remove the vote + vote.delete() + else: + # Different vote type, so update the vote + vote.vote_type = vote_type + vote.save() + + # After processing the vote, redirect back to the topic page + return redirect("forum_topic", category_slug=reply.topic.category.slug, topic_id=reply.topic.id) + + except ForumReply.DoesNotExist: + messages.error(request, "Reply not found") + return redirect("forum_categories") + + +def topic_detail(request, pk): + topic = get_object_or_404(ForumTopic, pk=pk) + + # Get the user's vote on this topic if any + user_topic_vote = None + if request.user.is_authenticated: + try: + vote = ForumVote.objects.get(topic=topic, user=request.user) + user_topic_vote = vote.vote_type + except ForumVote.DoesNotExist: + pass + + # Get user votes on replies + user_reply_votes = {} + if request.user.is_authenticated: + reply_votes = ForumVote.objects.filter(reply__topic=topic, user=request.user).values_list( + "reply_id", "vote_type" + ) + user_reply_votes = dict(reply_votes) + + context = { + "topic": topic, + "user_topic_vote": user_topic_vote, + "user_reply_votes": user_reply_votes, + # other context variables + } + + return render(request, "web/forum/topic.html", context) + + +def contributors_list_view(request): + # Check if cached data is available + cached_context = cache.get("contributors_context") + if cached_context: + return render(request, "web/contributors_list.html", cached_context) + + # Initialize a dictionary to track contributor stats + contributor_stats = {} + + # Function to add a contributor to our stats dictionary + def add_contributor(username, avatar_url, profile_url): + if username not in contributor_stats: + contributor_stats[username] = { + "username": username, + "avatar_url": avatar_url, + "profile_url": profile_url, + "merged_pr_count": 0, + "closed_pr_count": 0, + "open_pr_count": 0, + "total_pr_count": 0, + "prs_url": f"https://github.com/AlphaOneLabs/education-website/pulls?q=is:pr+author:{username}", + } + + try: + # Fetch closed PRs first (includes both merged and non-merged closed PRs) + closed_prs = [] + for page in range(1, 11): # Limit to 10 pages to prevent hitting API rate limits + response = github_api_request( + f"{GITHUB_API_BASE}/repos/AlphaOneLabs/education-website/pulls", + params={"state": "closed", "per_page": 100, "page": page}, + ) + if not response or len(response) == 0: + break + + closed_prs.extend(response) + time.sleep(0.5) # Add delay to avoid hitting rate limits + + # Process closed PRs + for pr in closed_prs: + username = pr["user"]["login"] + + # Skip bots and specific users + if "[bot]" in username or "dependabot" in username or username == "A1L13N": + continue + + avatar_url = pr["user"]["avatar_url"] + profile_url = pr["user"]["html_url"] + + # Add to our tracking + add_contributor(username, avatar_url, profile_url) + + # Update the appropriate count based on whether it was merged + if pr["merged_at"]: + contributor_stats[username]["merged_pr_count"] += 1 + else: + contributor_stats[username]["closed_pr_count"] += 1 + + # Now fetch open PRs + open_prs = [] + for page in range(1, 6): # Limit to 5 pages for open PRs + response = github_api_request( + f"{GITHUB_API_BASE}/repos/AlphaOneLabs/education-website/pulls", + params={"state": "open", "per_page": 100, "page": page}, + ) + if not response or len(response) == 0: + break + + open_prs.extend(response) + time.sleep(0.5) # Add delay to avoid hitting rate limits + + # Process open PRs + for pr in open_prs: + username = pr["user"]["login"] + + # Skip bots and specific users + if "[bot]" in username or "dependabot" in username or username == "A1L13N": + continue + + avatar_url = pr["user"]["avatar_url"] + profile_url = pr["user"]["html_url"] + + # Add to our tracking + add_contributor(username, avatar_url, profile_url) + + # Update open PR count + contributor_stats[username]["open_pr_count"] += 1 + + # Calculate total PR count and filter out users with no merged PRs + contributors = [] + for username, stats in contributor_stats.items(): + # Skip contributors with no merged PRs + if stats["merged_pr_count"] == 0: + continue + + # Calculate total PR count + stats["total_pr_count"] = stats["merged_pr_count"] + stats["closed_pr_count"] + stats["open_pr_count"] + + # Calculate a smart score that prioritizes merged PRs but penalizes imbalances + # Formula: (merged_pr_count * 10) - penalties for imbalanced contributions + smart_score = stats["merged_pr_count"] * 10 + + # Penalize if closed PRs are more than half of merged PRs (could indicate issues with code quality) + if stats["closed_pr_count"] > (stats["merged_pr_count"] / 2): + smart_score -= (stats["closed_pr_count"] - (stats["merged_pr_count"] / 2)) * 2 + + # Penalize if open PRs are more than merged PRs (could indicate abandonment issues) + if stats["open_pr_count"] > stats["merged_pr_count"]: + smart_score -= stats["open_pr_count"] - stats["merged_pr_count"] + + # Calculate a contribution ratio: merged/(total) - higher is better + if stats["total_pr_count"] > 0: + stats["contribution_ratio"] = stats["merged_pr_count"] / stats["total_pr_count"] + else: + stats["contribution_ratio"] = 0 + + # Store the smart score + stats["smart_score"] = smart_score + + contributors.append(stats) + + # Sort by smart score (primary) and then by merged PR count (secondary) + contributors.sort(key=lambda x: (x["smart_score"], x["merged_pr_count"]), reverse=True) + + # Store the context in cache for 12 hours + context = {"contributors": contributors} + cache.set("contributors_context", context, 12 * 60 * 60) + + return render(request, "web/contributors_list.html", context) + + except Exception as e: + # Log the error + print(f"Error fetching contributors: {e}") + # Return an empty list in case of error + return render(request, "web/contributors_list.html", {"contributors": []}) + + +@login_required +def video_request_list(request): + """View for listing video requests with optional category filtering.""" + # Get category filter from query params + selected_category = request.GET.get("category") + + # Base queryset + requests = VideoRequest.objects.select_related("requester", "category").order_by("-created_at") + + # Apply category filter if provided + if selected_category: + requests = requests.filter(category__slug=selected_category) + selected_category_obj = get_object_or_404(Subject, slug=selected_category) + selected_category_display = selected_category_obj.name + else: + selected_category_display = None + + # Get category counts for sidebar + category_counts = { + category.slug: VideoRequest.objects.filter(category=category).count() for category in Subject.objects.all() + } + + # Context + context = { + "requests": requests, + "categories": Subject.objects.all(), + "category_counts": category_counts, + "selected_category": selected_category, + "selected_category_display": selected_category_display, + } + + return render(request, "videos/request_list.html", context) + + +@login_required +def submit_video_request(request: HttpRequest) -> HttpResponse: + """View for submitting a new video request.""" + if request.method == "POST": + form = VideoRequestForm(request.POST) + if form.is_valid(): + video_request = form.save(commit=False) + video_request.requester = request.user + video_request.save() + + messages.success(request, "Your video request has been submitted successfully!") + return redirect("video_request_list") + else: + form = VideoRequestForm() + + return render(request, "videos/submit_request.html", {"form": form}) + + +class SurveyListView(LoginRequiredMixin, ListView): + model = Survey + template_name = "surveys/list.html" + login_url = "/accounts/login/" + + +class SurveyCreateView(LoginRequiredMixin, CreateView): + model = Survey + form_class = SurveyForm + template_name = "surveys/create.html" + login_url = "/accounts/login/" + + def form_valid(self, form): + form.instance.author = self.request.user + survey = form.save() + + # Process questions + question_texts = self.request.POST.getlist("question_text[]") + question_types = self.request.POST.getlist("question_type[]") + question_choices = self.request.POST.getlist("question_choices[]") + scale_mins = self.request.POST.getlist("scale_min[]") + scale_maxs = self.request.POST.getlist("scale_max[]") + + for i, (q_text, q_type) in enumerate(zip(question_texts, question_types)): + if q_text.strip(): + # Convert scale values to integers with proper error handling + scale_min = 1 + scale_max = 5 + + try: + if q_type == "scale" and i < len(scale_mins) and scale_mins[i]: + scale_min = int(scale_mins[i]) + if q_type == "scale" and i < len(scale_maxs) and scale_maxs[i]: + scale_max = int(scale_maxs[i]) + except (ValueError, IndexError): + # Use defaults if there's an error + scale_min = 1 + scale_max = 5 + + question = Question.objects.create( + survey=survey, + text=q_text.strip(), + type=q_type, # This should match your model field name + scale_min=scale_min, + scale_max=scale_max, + ) + + # Handle choices based on question type + if q_type == "true_false": + Choice.objects.create(question=question, text="True") + Choice.objects.create(question=question, text="False") + elif q_type == "scale": + for num in range(question.scale_min, question.scale_max + 1): + Choice.objects.create(question=question, text=str(num)) + elif q_type in ["mcq", "checkbox"]: + # Make sure we have choices for this question + if i < len(question_choices): + for choice_text in question_choices[i].split("\n"): + if choice_text.strip(): + Choice.objects.create(question=question, text=choice_text.strip()) + + return redirect("surveys") + + +class SurveyDetailView(LoginRequiredMixin, DetailView): + model = Survey + template_name = "surveys/detail.html" + login_url = "/accounts/login/" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Check if user has already submitted this survey + context["already_submitted"] = Response.objects.filter( + user=self.request.user, question__survey=self.object + ).exists() + # Check if user is the creator of this survey + context["is_creator"] = self.object.author == self.request.user + return context + + +@login_required +def submit_survey(request, pk): + survey = get_object_or_404(Survey, pk=pk) + + # Check if user already submitted + if Response.objects.filter(user=request.user, question__survey=survey).exists(): + messages.error(request, "You've already submitted this survey!") + return redirect("survey-detail", pk=survey.id) + + if request.method == "POST": + for question in survey.question_set.all(): + if question.required and not request.POST.get(f"question_{question.id}"): + messages.error(request, f"Please answer required question: {question.text}") + return redirect("survey-detail", pk=survey.id) + + if question.type == "checkbox": + choices = request.POST.getlist(f"question_{question.id}") + for choice_id in choices: + try: + choice = Choice.objects.get(id=choice_id) + if choice.question_id == question.id: + Response.objects.create(user=request.user, question=question, choice=choice) + else: + messages.error(request, "Invalid choice selected") + return redirect("survey-detail", pk=survey.id) + except Choice.DoesNotExist: + messages.error(request, "Invalid choice selected") + return redirect("survey-detail", pk=survey.id) + elif question.type == "text": + Response.objects.create( + user=request.user, question=question, text_answer=request.POST.get(f"question_{question.id}") + ) + else: + choice_id = request.POST.get(f"question_{question.id}") + if choice_id: + try: + choice = Choice.objects.get(id=choice_id) + if choice.question_id == question.id: + Response.objects.create(user=request.user, question=question, choice=choice) + else: + messages.error(request, "Invalid choice selected") + return redirect("survey-detail", pk=survey.id) + except Choice.DoesNotExist: + messages.error(request, "Invalid choice selected") + return redirect("survey-detail", pk=survey.id) + + messages.success(request, "Survey submitted successfully!") + return redirect("survey-results", pk=survey.id) + + return redirect("survey-detail", pk=survey.id) + + +class SurveyResultsView(LoginRequiredMixin, DetailView): + model = Survey + template_name = "surveys/results.html" + login_url = "/accounts/login/" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Process survey results + results = [] + total_participants = 0 + most_answered_question = None + max_responses = 0 + top_choice = None + bottom_choice = None + overall_top_choice_count = 0 + overall_bottom_choice_count = float("inf") + total_possible_responses = 0 + total_actual_responses = 0 + avg_completion_time = None + context["avg_completion_time"] = avg_completion_time + + for question in self.object.question_set.all(): + choices_data = [] + question_total = 0 + + # Process each choice for this question + for choice in question.choice_set.all(): + count = choice.response_set.count() if hasattr(choice, "response_set") else 0 + + question_total += count + choices_data.append({"text": choice.text, "count": count}) + + if question_total == 0: + continue + if question_total > 0: + total_possible_responses += total_participants * 1 # Each participant could answer this question + total_actual_responses += question_total + + if question_total > max_responses: + max_responses = question_total + most_answered_question = question + + for choice in choices_data: + choice["percentage"] = round((choice["count"] / question_total * 100), 1) if question_total > 0 else 0 + + if choice["count"] > overall_top_choice_count: + overall_top_choice_count = choice["count"] + top_choice = choice + + if choice["count"] > 0 and choice["count"] < overall_bottom_choice_count: + overall_bottom_choice_count = choice["count"] + bottom_choice = choice + + results.append({"question": question, "choices": choices_data, "total": question_total}) + + total_participants = max(total_participants, question_total) + engagement_score = 0 + if total_possible_responses > 0: + engagement_score = (total_actual_responses / total_possible_responses) * 100 + + context["engagement_score"] = round(engagement_score, 1) + target_participants = getattr(self.object, "target_participants", 100) # default to 100 if not set + response_rate = (total_participants / target_participants * 100) if target_participants > 0 else 0 + + context.update( + { + "results": results, + "total_participants": total_participants, + "response_rate": min(response_rate, 100), # Cap at 100% + "most_answered_question": most_answered_question, + "top_choice": top_choice, + "bottom_choice": bottom_choice, + "is_creator": self.object.author == self.request.user if hasattr(self.object, "author") else False, + } + ) + + chart_data = [] + for result in results: + chart_data.append( + { + "question_id": result["question"].id, + "labels": [choice["text"] for choice in result["choices"]], + "data": [choice["count"] for choice in result["choices"]], + } + ) + context["chart_data_json"] = json.dumps(chart_data) + + return context + + +class SurveyDeleteView(LoginRequiredMixin, DeleteView): + model = Survey + success_url = reverse_lazy("surveys") # Use reverse_lazy + template_name = "surveys/delete.html" + login_url = "/accounts/login/" + + def get_queryset(self): + # Override queryset to only allow creator to access the survey for deletion + base_qs = super().get_queryset() + return base_qs.filter(author=self.request.user) + + def handle_no_permission(self): + messages.error(self.request, "You can only delete surveys that you created.") + return redirect("surveys") + + +@login_required +def join_session_waiting_room(request, course_slug): + """View for joining a session waiting room for the next session of a course.""" + course = get_object_or_404(Course, slug=course_slug) + + # Get or create the session waiting room for this course + session_waiting_room, created = WaitingRoom.objects.get_or_create( + course=course, status="open", defaults={"status": "open"} + ) + + # Check if the waiting room is open + if session_waiting_room.status != "open": + messages.error(request, "This session waiting room is no longer open for joining.") + return redirect("course_detail", slug=course_slug) + + # Add the user to participants if not already in + if request.user not in session_waiting_room.participants.all(): + session_waiting_room.participants.add(request.user) + next_session = session_waiting_room.get_next_session() + if next_session: + next_session_date = next_session.start_time.strftime("%B %d, %Y at %I:%M %p") + messages.success( + request, + f"You have joined the waiting room for the next session of {course.title}. " + f"Next session is on {next_session_date}.", + ) + else: + messages.success( + request, + f"You have joined the waiting room for the next session of {course.title}. " + f"You'll be notified when a new session is scheduled.", + ) + notify_teacher_waiting_room_join(session_waiting_room, request.user) + else: + messages.info(request, "You are already in the waiting room for the next session of this course.") + + return redirect("course_detail", slug=course_slug) + + +@login_required +def leave_session_waiting_room(request, course_slug): + """View for leaving a session waiting room.""" + course = get_object_or_404(Course, slug=course_slug) + + try: + session_waiting_room = WaitingRoom.objects.get(course=course, status="open") + except WaitingRoom.DoesNotExist: + messages.info(request, "No session waiting room found for this course.") + return redirect("course_detail", slug=course_slug) + + # Remove the user from participants + if request.user in session_waiting_room.participants.all(): + session_waiting_room.participants.remove(request.user) + messages.success(request, f"You have left the session waiting room for {course.title}") + else: + messages.info(request, "You are not in the session waiting room for this course.") + + return redirect("course_detail", slug=course_slug) diff --git a/web/virtual_lab/__init__.py b/web/virtual_lab/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/virtual_lab/apps.py b/web/virtual_lab/apps.py new file mode 100644 index 0000000..fc003d2 --- /dev/null +++ b/web/virtual_lab/apps.py @@ -0,0 +1,10 @@ +"""Django AppConfig for the Virtual Lab application.""" + +from django.apps import AppConfig + + +class VirtualLabConfig(AppConfig): + """Configuration for the Virtual Lab Django application.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "web.virtual_lab" diff --git a/web/virtual_lab/css/virtual_lab.css b/web/virtual_lab/css/virtual_lab.css new file mode 100644 index 0000000..e69de29 diff --git a/web/virtual_lab/models.py b/web/virtual_lab/models.py new file mode 100644 index 0000000..e69de29 diff --git a/web/virtual_lab/static/virtual_lab/js/chemistry/ph_indicator.js b/web/virtual_lab/static/virtual_lab/js/chemistry/ph_indicator.js new file mode 100644 index 0000000..42149bd --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/chemistry/ph_indicator.js @@ -0,0 +1,138 @@ +// static/virtual_lab/js/chemistry/ph_indicator.js + +const width = 600, height = 400; + +// Canvases & contexts +const indCv = document.getElementById('indicator-canvas'), + indCtx = indCv.getContext('2d'); +const dropCv = document.getElementById('drop-canvas'), + dropCtx = dropCv.getContext('2d'); +const confCv = document.getElementById('confetti-canvas'), + confCtx = confCv.getContext('2d'); + +const updateBtn = document.getElementById('update-btn'), + resetBtn = document.getElementById('reset-btn'), + hintEl = document.getElementById('hint'), + propEl = document.getElementById('property'), + phInput = document.getElementById('solution-ph'); + +let confettiParticles = []; + +// 1. pH → color +function getColor(ph) { + if (ph < 3) return '#ff0000'; + if (ph < 6) return '#ff9900'; + if (ph < 8) return '#ffff00'; + if (ph < 11) return '#66ff66'; + return '#0000ff'; +} + +// 2. pH → property +function getProperty(ph) { + if (ph < 7) return '{% trans "Acidic" %}'; + if (ph === 7) return '{% trans "Neutral" %}'; + return '{% trans "Basic" %}'; +} + +// Draw the main indicator rectangle + pH label +function drawIndicator(ph) { + indCtx.clearRect(0, 0, width, height); + // Fill + indCtx.fillStyle = getColor(ph); + indCtx.fillRect(100, 75, 400, 250); + // Border pulse + indCtx.lineWidth = 8; + indCtx.strokeStyle = '#333'; + indCtx.strokeRect(100, 75, 400, 250); + // Label + indCtx.fillStyle = '#000'; + indCtx.font = '24px Arial'; + indCtx.fillText(`pH: ${ph.toFixed(1)}`, 260, 360); +} + +// Animate a single drop falling & splashing +function animateDrop(ph) { + dropCtx.clearRect(0,0,width,height); + const dropX = 300, startY = 0, groundY = 75; + let y = startY; + const id = setInterval(() => { + dropCtx.clearRect(0,0,width,height); + dropCtx.fillStyle = '#66b3ff'; + dropCtx.beginPath(); + dropCtx.arc(dropX, y, 6, 0, 2*Math.PI); + dropCtx.fill(); + y += 8; + if (y > groundY) { + clearInterval(id); + // Splash circle + let r = 0; + const sid = setInterval(()=>{ + dropCtx.clearRect(0,0,width,height); + dropCtx.beginPath(); + dropCtx.arc(dropX, groundY, r, 0, 2*Math.PI); + dropCtx.strokeStyle = '#66b3ff'; + dropCtx.lineWidth = 2; + dropCtx.stroke(); + r += 2; + if (r > 40) { + clearInterval(sid); + dropCtx.clearRect(0,0,width,height); + } + }, 30); + } + }, 30); +} + +// Create confetti particles when neutral +function spawnConfetti() { + confettiParticles = Array.from({length:100}, () => ({ + x: Math.random()*width, + y: -10, + vy: 2 + Math.random()*2, + color: `hsl(${Math.random()*360},70%,60%)` + })); + requestAnimationFrame(updateConfetti); +} + +function updateConfetti() { + confCtx.clearRect(0,0,width,height); + confettiParticles.forEach(p => { + confCtx.fillStyle = p.color; + confCtx.fillRect(p.x, p.y, 6, 6); + p.y += p.vy; + }); + confettiParticles = confettiParticles.filter(p => p.y < height); + if (confettiParticles.length) requestAnimationFrame(updateConfetti); +} + +// Update entire UI on pH change +function updateUI(ph) { + drawIndicator(ph); + animateDrop(ph); + hintEl.innerText = `🎉 Indicator shows ${getProperty(ph)}!`; + propEl.innerText = `{% trans "Solution is" %} ${getProperty(ph)}.`; + if (ph === 7) spawnConfetti(); +} + +// Button handlers +updateBtn.addEventListener('click', ()=>{ + let ph = parseFloat(phInput.value); + if (isNaN(ph) || ph < 0 || ph > 14) { + hintEl.innerText = '{% trans "Please enter a valid pH between 0 and 14." %}'; + return; + } + updateUI(ph); +}); + +resetBtn.addEventListener('click', ()=>{ + indCtx.clearRect(0,0,width,height); + dropCtx.clearRect(0,0,width,height); + confCtx.clearRect(0,0,width,height); + phInput.value = 7; + hintEl.innerText = '{% trans "Enter a pH value (0–14) and click Update to see the color change." %}'; + propEl.innerText = ''; + drawIndicator(7); +}); + +// Initial draw +window.addEventListener('load', ()=> drawIndicator(7)); diff --git a/web/virtual_lab/static/virtual_lab/js/chemistry/precipitation.js b/web/virtual_lab/static/virtual_lab/js/chemistry/precipitation.js new file mode 100644 index 0000000..15bd316 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/chemistry/precipitation.js @@ -0,0 +1,134 @@ +// static/virtual_lab/js/chemistry/precipitation.js + +// Canvases +const beakerCanvas = document.getElementById('beaker-canvas'); +const bctx = beakerCanvas.getContext('2d'); +const swirlCanvas = document.getElementById('swirl-canvas'); +const sctx = swirlCanvas.getContext('2d'); +const precipCanvas = document.getElementById('precip-canvas'); +const pctx = precipCanvas.getContext('2d'); + +// Controls & status +const addBtn = document.getElementById('add-reagent'); +const stirBtn = document.getElementById('stir-btn'); +const resetBtn = document.getElementById('reset-btn'); +const hintEl = document.getElementById('hint'); +const propEl = document.getElementById('property'); + +let swirlAngle = 0, + swirlRAF, + precipParticles = [], + stage = 0; // 0=init,1=reagent,2=stirring,3=precipitating,4=done + +// Draw beaker outline +function drawBeaker() { + bctx.clearRect(0,0,600,400); + bctx.strokeStyle = '#333'; + bctx.lineWidth = 3; + bctx.strokeRect(200,100,200,250); +} + +// Swirl animation +function drawSwirl() { + sctx.clearRect(0,0,600,400); + const cx=300, cy=300, r=100; + sctx.strokeStyle = 'rgba(128,0,128,0.5)'; + sctx.lineWidth = 4; + sctx.beginPath(); + sctx.arc(cx,cy,r, swirlAngle, swirlAngle + Math.PI*1.5); + sctx.stroke(); + swirlAngle += 0.05; + swirlRAF = requestAnimationFrame(drawSwirl); +} + +// Start stirring +function startStir() { + stage = 2; + updateHint('🌀 Stirring to initiate precipitation...'); + drawBeaker(); + drawSwirl(); + stirBtn.disabled = true; + stirBtn.classList.add('opacity-50'); + setTimeout(stopStir, 2000); +} + +// Stop swirling and spawn precipitate +function stopStir() { + cancelAnimationFrame(swirlRAF); + sctx.clearRect(0,0,600,400); + stage = 3; + updateHint('🌧️ Precipitate forming...'); + spawnParticles(); +} + +// Spawn and animate precipitate +function spawnParticles() { + precipParticles = []; + for (let i=0; i<150; i++) { + precipParticles.push({ + x: 220 + Math.random()*160, + y: 110, + vy: 1 + Math.random()*1.5 + }); + } + animateParticles(); +} + +// Animate particles falling +function animateParticles() { + pctx.clearRect(0,0,600,400); + precipParticles.forEach(p => { + pctx.fillStyle = '#666'; + pctx.beginPath(); + pctx.arc(p.x, p.y, 4, 0,2*Math.PI); + pctx.fill(); + p.y += p.vy; + }); + precipParticles = precipParticles.filter(p => p.y < 350); + if (precipParticles.length) { + requestAnimationFrame(animateParticles); + } else { + stage = 4; + updateHint('✅ Precipitation complete!'); + propEl.innerText = 'Precipitate Present'; + } +} + +// Update hint text +function updateHint(txt) { + hintEl.innerText = txt; +} + +// Reset everything +function resetAll() { + cancelAnimationFrame(swirlRAF); + precipParticles = []; + sctx.clearRect(0,0,600,400); + pctx.clearRect(0,0,600,400); + propEl.innerText = ''; + stage = 0; + updateHint('Click Add Reagent to begin.'); + addBtn.disabled = false; + stirBtn.disabled = true; + stirBtn.classList.add('opacity-50'); + drawBeaker(); +} + +// Event handlers +addBtn.addEventListener('click', () => { + stage = 1; + updateHint('➕ Reagent added! Now stir the solution.'); + addBtn.disabled = true; + stirBtn.disabled = false; + stirBtn.classList.remove('opacity-50'); +}); +stirBtn.addEventListener('click', startStir); +resetBtn.addEventListener('click', resetAll); + +// Initial setup +window.addEventListener('load', () => { + drawBeaker(); + updateHint('Click Add Reagent to begin.'); + stirBtn.disabled = true; + stirBtn.classList.add('opacity-50'); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/chemistry/reaction_rate.js b/web/virtual_lab/static/virtual_lab/js/chemistry/reaction_rate.js new file mode 100644 index 0000000..d3c7f79 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/chemistry/reaction_rate.js @@ -0,0 +1,82 @@ +// static/virtual_lab/js/chemistry/reaction_rate.js + +const canvas = document.getElementById('reaction-canvas'); +const ctx = canvas.getContext('2d'); + +let initialConc, conc, time = 0, loopID; + +// Draws a test-tube shape and fills it proportional to conc +function drawTestTube(c) { + ctx.clearRect(0,0,600,400); + // Tube outline + ctx.strokeStyle = '#555'; + ctx.lineWidth = 4; + ctx.strokeRect(250,50,100,300); + // Liquid fill + const fillHeight = Math.max(0, Math.min(300, (c/initialConc)*300)); + ctx.fillStyle = '#a6f6a6'; + ctx.fillRect(254,350-fillHeight,92,fillHeight); + // Label concentration + ctx.fillStyle = '#000'; + ctx.font = '16px Arial'; + ctx.fillText(`[A]: ${c.toFixed(2)} M`, 20, 380); +} + +// Provides textual hints based on current conc +function updateHint(c) { + const hint = document.getElementById('hint'); + if (c <= 0) hint.innerText = '{% trans "Reaction has completed!" %}'; + else if (c <= initialConc/2) hint.innerText = '{% trans "Half-life reached." %}'; + else hint.innerText = '{% trans "Reaction proceeding..." %}'; +} + +// Displays final “Reaction Complete” message +function displayProperty() { + document.getElementById('property').innerText = '{% trans "Reaction Complete" %}'; +} + +// Advances the reaction in small time steps +function startReaction() { + initialConc = parseFloat(document.getElementById('reactant-conc').value) || 1.0; + conc = initialConc; + time = 0; + clearInterval(loopID); + document.getElementById('property').innerText = ''; + + loopID = setInterval(() => { + if (conc <= 0.01) { + clearInterval(loopID); + conc = 0; + drawTestTube(0); + document.getElementById('elapsed-time').innerText = time; + updateHint(0); + displayProperty(); + return; + } + // Simple first-order decay: dc/dt = -k * c + const k = 0.1; // rate constant + conc -= k * conc * 0.5; // ∆t = 0.5 s + time += 0.5; + drawTestTube(conc); + document.getElementById('elapsed-time').innerText = time.toFixed(1); + updateHint(conc); + }, 500); +} + +// Resets simulation +function resetReaction() { + clearInterval(loopID); + document.getElementById('elapsed-time').innerText = '0'; + document.getElementById('hint').innerText = ''; + document.getElementById('property').innerText = ''; + initialConc = parseFloat(document.getElementById('reactant-conc').value) || 1.0; + conc = initialConc; + drawTestTube(conc); +} + +// Initialize on load +window.addEventListener('load', () => { + initialConc = parseFloat(document.getElementById('reactant-conc').value) || 1.0; + conc = initialConc; + drawTestTube(conc); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/chemistry/solubility.js b/web/virtual_lab/static/virtual_lab/js/chemistry/solubility.js new file mode 100644 index 0000000..f5e570c --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/chemistry/solubility.js @@ -0,0 +1,73 @@ +// static/virtual_lab/js/chemistry/solubility.js + +const canvas = document.getElementById('solubility-canvas'); +const ctx = canvas.getContext('2d'); + +let dissolved = 0; +const limit = 10; // grams + +// Draws beaker and liquid fill based on dissolved amount +function drawBeaker() { + ctx.clearRect(0, 0, 600, 400); + // Beaker outline + ctx.strokeStyle = '#333'; + ctx.lineWidth = 2; + ctx.strokeRect(200, 100, 200, 250); + // Liquid fill + const height = Math.min((dissolved / limit) * 240, 240); + ctx.fillStyle = '#f3e6b8'; + ctx.fillRect(202, 350 - height, 196, height); + // Label + ctx.fillStyle = '#000'; + ctx.font = '16px Arial'; + ctx.fillText(`${dissolved.toFixed(1)}g / ${limit}g`, 250, 380); +} + +// Updates hint text based on current dissolved amount +function updateHint() { + const hintEl = document.getElementById('hint'); + if (dissolved < limit) { + hintEl.innerText = '🔽 Solution is unsaturated. Keep adding solute.'; + } else if (dissolved === limit) { + hintEl.innerText = '✅ Saturation point reached!'; + } else { + hintEl.innerText = '⚠️ Supersaturated! Excess solute will precipitate.'; + } +} + +// Displays final property once user is done +function displayProperty() { + const propEl = document.getElementById('property'); + if (dissolved < limit) { + propEl.innerText = 'Solution is Unsaturated'; + } else if (dissolved === limit) { + propEl.innerText = 'Solution is Saturated'; + } else { + propEl.innerText = 'Solution is Supersaturated'; + } +} + +// Called when user clicks “Add Solute” +function addSolute() { + const amtInput = parseFloat(document.getElementById('solute-amt').value) || 0; + dissolved += amtInput; + document.getElementById('dissolved-amt').innerText = dissolved.toFixed(1); + drawBeaker(); + updateHint(); + displayProperty(); +} + +// Called when user clicks “Reset” +function resetSolubility() { + dissolved = 0; + document.getElementById('dissolved-amt').innerText = '0'; + document.getElementById('property').innerText = ''; + drawBeaker(); + updateHint(); +} + +// Initial render on page load +window.addEventListener('load', () => { + drawBeaker(); + updateHint(); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/chemistry/titration.js b/web/virtual_lab/static/virtual_lab/js/chemistry/titration.js new file mode 100644 index 0000000..a5704d0 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/chemistry/titration.js @@ -0,0 +1,117 @@ +// static/virtual_lab/js/chemistry/titration.js + +const beakerCanvas = document.getElementById('titration-canvas'); +const bctx = beakerCanvas.getContext('2d'); +const dropCanvas = document.getElementById('drop-canvas'); +const dctx = dropCanvas.getContext('2d'); + +let titrantVolume = 0, loopID; + +// Draw beaker + liquid +function drawBeaker(pH) { + bctx.clearRect(0,0,600,400); + bctx.fillStyle = '#ccc'; bctx.fillRect(200,100,200,250); + bctx.fillStyle = getColor(pH); bctx.fillRect(205,105,190,240); + bctx.strokeStyle = '#333'; bctx.strokeRect(200,100,200,250); + bctx.fillStyle = '#000'; bctx.font = '16px Arial'; + bctx.fillText(`pH: ${pH.toFixed(2)}`, 240, 380); +} + +// Indicator color mapping +function getColor(pH) { + if (pH < 4) return '#ff4d4d'; + if (pH < 6) return '#ff944d'; + if (pH < 7.5) return '#ffff66'; + if (pH < 9) return '#66ff99'; + return '#66b3ff'; +} + +// Compute pH +function computePH(aC,bC,aV,bV) { + const molA = aC*aV/1000, molB = bC*bV/1000; + const net = molB - molA, totV = (aV + bV)/1000; + if (Math.abs(net) < 1e-6) return 7; + return net < 0 + ? -Math.log10(-net/totV) + : 14 + Math.log10(net/totV); +} + +// Update the hint message +function updateHint(pH) { + const hint = document.getElementById('hint'); + if (pH < 3) hint.innerText = '🔴 Very acidic! Add more base slowly.'; + else if (pH < 6) hint.innerText = '🟠 Still acidic—keep titrant coming.'; + else if (pH < 6.5) hint.innerText = '🟡 Approaching endpoint—go slow!'; + else if (pH < 7.5) hint.innerText = '🟢 Almost neutral—nice!'; + else if (pH < 9) hint.innerText = '🔵 Slightly basic now.'; + else hint.innerText = '💙 Basic—endpoint passed.'; +} + +// Display final property +function displayProperty(pH) { + const propEl = document.getElementById('property'); + let prop; + if (pH < 7) prop = '{% trans "Acidic" %}'; + else if (pH === 7) prop = '{% trans "Neutral" %}'; + else prop = '{% trans "Basic" %}'; + propEl.innerText = `{% trans "Solution is" %} ` + prop; +} + +// Animate one drop +function animateDrop() { + dctx.clearRect(0,0,600,400); + let y = 0, x = 300; + const id = setInterval(()=>{ + dctx.clearRect(0,0,600,400); + dctx.fillStyle = '#66b3ff'; + dctx.beginPath(); dctx.arc(x,y,6,0,2*Math.PI); dctx.fill(); + y += 5; + if (y > 110) { + clearInterval(id); + setTimeout(()=> dctx.clearRect(0,0,600,400), 50); + } + }, 20); +} + +// Start titration +function startTitration() { + const aC = parseFloat(document.getElementById('acid-conc').value); + const bC = parseFloat(document.getElementById('base-conc').value); + const aV = parseFloat(document.getElementById('acid-vol').value); + titrantVolume = 0; + clearInterval(loopID); + + loopID = setInterval(()=>{ + titrantVolume += 0.2; + if (titrantVolume > 50) { + clearInterval(loopID); + const finalPH = computePH(aC,bC,aV,titrantVolume); + displayProperty(finalPH); + return; + } + animateDrop(); + setTimeout(()=>{ + const pH = computePH(aC,bC,aV,titrantVolume); + drawBeaker(pH); + document.getElementById('titrant-volume').innerText = titrantVolume.toFixed(1); + updateHint(pH); + }, 400); + }, 600); +} + +// Reset everything +function resetTitration() { + clearInterval(loopID); + dctx.clearRect(0,0,600,400); + titrantVolume = 0; + document.getElementById('titrant-volume').innerText = '0'; + document.getElementById('hint').innerText = ''; + document.getElementById('property').innerText = ''; + drawBeaker(1); +} + +// Initial draw +window.addEventListener('load', () => { + drawBeaker(1); + updateHint(1); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/code_editor.js b/web/virtual_lab/static/virtual_lab/js/code_editor.js new file mode 100644 index 0000000..f531817 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/code_editor.js @@ -0,0 +1,53 @@ +// static/virtual_lab/js/code_editor.js + +// Simple CSRF helper +function getCookie(name) { + const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); + return match ? decodeURIComponent(match[2]) : null; +} + +// Bootstrap Ace +const editor = ace.edit("editor"); +editor.setTheme("ace/theme/github"); +editor.session.setMode("ace/mode/python"); +editor.setOptions({ fontSize: "14px", showPrintMargin: false }); + +const runBtn = document.getElementById("run-btn"); +const outputEl = document.getElementById("output"); +const stdinEl = document.getElementById("stdin-input"); +const langSel = document.getElementById("language-select"); + +runBtn.addEventListener("click", () => { + const code = editor.getValue(); + const stdin = stdinEl.value; + const language = langSel.value; + + if (!code.trim()) { + outputEl.textContent = "🛑 Please type some code first."; + return; + } + outputEl.textContent = "Running…"; + runBtn.disabled = true; + + fetch(window.EVALUATE_CODE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCookie("csrftoken") + }, + body: JSON.stringify({ code, language, stdin }) + }) + .then(res => res.json()) + .then(data => { + let out = ""; + if (data.stderr) out += `ERROR:\n${data.stderr}\n`; + if (data.stdout) out += data.stdout; + outputEl.textContent = out || "[no output]"; + }) + .catch(err => { + outputEl.textContent = `Request failed: ${err.message}`; + }) + .finally(() => { + runBtn.disabled = false; + }); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/common.js b/web/virtual_lab/static/virtual_lab/js/common.js new file mode 100644 index 0000000..f7b3915 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/common.js @@ -0,0 +1,41 @@ +// Helper to get the CSRF token from cookies: +function getCSRFToken() { + const name = 'csrftoken'; + const cookies = document.cookie.split(';'); + for (let c of cookies) { + c = c.trim(); + if (c.startsWith(name + '=')) { + return decodeURIComponent(c.substring(name.length + 1)); + } + } + return ''; +} + +// A simple wrapper for POSTing JSON with CSRF: +function ajaxPost(url, data, onSuccess, onError) { + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken(), + }, + body: JSON.stringify(data), + }) + .then((resp) => { + if (!resp.ok) throw new Error('Network response was not OK'); + return resp.json(); + }) + .then(onSuccess) + .catch(onError); +} + +// A simple wrapper for GETting JSON: +function ajaxGet(url, onSuccess, onError) { + fetch(url) + .then((resp) => { + if (!resp.ok) throw new Error('Network response was not OK'); + return resp.json(); + }) + .then(onSuccess) + .catch(onError); +} diff --git a/web/virtual_lab/static/virtual_lab/js/physics_electrical_circuit.js b/web/virtual_lab/static/virtual_lab/js/physics_electrical_circuit.js new file mode 100644 index 0000000..a3eb268 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/physics_electrical_circuit.js @@ -0,0 +1,404 @@ +// web/virtual_lab/static/virtual_lab/js/physics_electrical_circuit.js + +document.addEventListener("DOMContentLoaded", () => { + // ==== 1. Tutorial Overlay Logic ==== + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + const steps = [ + ["Use the sliders to set battery voltage \(V_0\), resistor \(R\), and capacitor \(C\)."], + ["Click “Start” to begin charging the capacitor through \(R\). Watch \(V_C(t)\) rise."], + ["Observe how \(I(t)\) decreases as the capacitor charges."], + ["After \(5\ tau\), answer the quiz questions."] + ]; + + let currentStep = 0; + function showStep(i) { + stepNumberElem.textContent = i + 1; + stepList.innerHTML = ""; + steps[i].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = i === 0; + nextBtn.textContent = i === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.style.display = "none"; + enableControls(); + } + }); + skipBtn.addEventListener("click", () => { + tutorialOverlay.style.display = "none"; + enableControls(); + }); + // ==== End Tutorial Overlay Logic ==== + + // ==== 2. DOM References & State ==== + const canvas = document.getElementById("circuit-canvas"); + const ctx = canvas.getContext("2d"); + + const VSlider = document.getElementById("V-slider"); + const VValue = document.getElementById("V-value"); + const RSlider = document.getElementById("R-slider"); + const RValue = document.getElementById("R-value"); + const CSlider = document.getElementById("C-slider"); + const CValue = document.getElementById("C-value"); + + const startBtn = document.getElementById("start-circuit"); + const stopBtn = document.getElementById("stop-circuit"); + const resetBtn = document.getElementById("reset-circuit"); + + const quizDiv = document.getElementById("postlab-quiz"); + + const readoutT = document.getElementById("readout-t"); + const readoutVc = document.getElementById("readout-vc"); + const readoutI = document.getElementById("readout-i"); + const readoutTau = document.getElementById("readout-tau"); + + const vcCtx = document.getElementById("vc-chart").getContext("2d"); + const iCtx = document.getElementById("i-chart").getContext("2d"); + + // Physical parameters (will update on slider changes) + let V0 = parseFloat(VSlider.value); // battery voltage in volts + let R = parseFloat(RSlider.value); // resistance in ohms + let C = parseFloat(CSlider.value) * 1e-6; // convert µF → F + + let tau = R * C; // time constant (s) + + // Simulation state + let t0 = null; + let animId = null; + let running = false; + let maxTime = 5 * tau; // simulate up to 5τ for quiz reveal + + // Chart.js setups + const vcData = { + labels: [], + datasets: [{ + label: "Vc(t) (V)", + data: [], + borderColor: "#1E40AF", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const vcChart = new Chart(vcCtx, { + type: "line", + data: vcData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "Vc (V)" }, suggestedMax: V0 } + }, + plugins: { legend: { display: false } } + } + }); + + const iData = { + labels: [], + datasets: [{ + label: "I(t) (A)", + data: [], + borderColor: "#DC2626", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const iChart = new Chart(iCtx, { + type: "line", + data: iData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "I (A)" } } + }, + plugins: { legend: { display: false } } + } + }); + + // Disable controls until tutorial ends + startBtn.disabled = true; + stopBtn.disabled = true; + resetBtn.disabled = true; + VSlider.disabled = true; + RSlider.disabled = true; + CSlider.disabled = true; + + // ==== 3. Enable Controls & Initial Draw ==== + function enableControls() { + startBtn.disabled = false; + resetBtn.disabled = false; + VSlider.disabled = false; + RSlider.disabled = false; + CSlider.disabled = false; + tau = R * C; + readoutTau.textContent = tau.toFixed(3); + drawCircuit(0); // initial draw at t=0 + } + + // Draw the schematic of battery→resistor→capacitor and a current arrow + function drawCircuit(t) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Coordinates for schematic + const leftX = 50; + const midX = 250; + const rightX = 450; + const centerY = canvas.height / 2; + + // 1) Draw battery as two plates + ctx.fillStyle = "#333"; + ctx.fillRect(leftX - 10, centerY - 40, 10, 80); // negative plate + ctx.fillRect(leftX + 10, centerY - 20, 5, 40); // positive plate + ctx.fillStyle = "#000"; + ctx.font = "14px sans-serif"; + ctx.fillText("Battery", leftX - 20, centerY - 50); + ctx.fillText(`V₀ = ${V0.toFixed(1)}V`, leftX - 30, centerY + 60); + + // 2) Draw resistor as zig-zag between leftX+20 and midX + drawResistor(leftX + 20, centerY, midX, centerY); + ctx.fillStyle = "#000"; + ctx.fillText(`R = ${R.toFixed(0)}Ω`, (leftX + 20 + midX) / 2 - 20, centerY - 20); + + // 3) Draw capacitor between midX+20 and midX+20 horizontally (two parallel plates) + ctx.strokeStyle = "#333"; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(midX + 20, centerY - 30); + ctx.lineTo(midX + 20, centerY + 30); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(midX + 45, centerY - 30); + ctx.lineTo(midX + 45, centerY + 30); + ctx.stroke(); + ctx.fillStyle = "#000"; + ctx.fillText(`C = ${(C * 1e6).toFixed(0)}µF`, midX + 5, centerY + 60); + + // 4) Draw wires connecting ends + ctx.strokeStyle = "#555"; + ctx.lineWidth = 2; + ctx.beginPath(); + // Top wire: battery positive → resistor start + ctx.moveTo(leftX + 10, centerY - 20); + ctx.lineTo(leftX + 20, centerY - 20); + // Continue through resistor zigzag (already drawn), then to capacitor start + ctx.lineTo(midX, centerY - 20); + ctx.lineTo(midX + 20, centerY - 20); + // From capacitor top plate back to battery negative via right wire + ctx.lineTo(midX + 45, centerY - 20); + ctx.lineTo(rightX, centerY - 20); + ctx.lineTo(rightX, centerY + 60); + // Bottom wire: capacitor bottom plate back to battery negative + ctx.lineTo(midX + 45, centerY + 60); + ctx.lineTo(midX + 20, centerY + 60); + ctx.lineTo(midX, centerY + 60); + ctx.lineTo(leftX + 20, centerY + 60); + ctx.lineTo(leftX + 10, centerY + 60); + ctx.stroke(); + + // 5) Compute Vc(t) and I(t) + const VC = V0 * (1 - Math.exp(-t / tau)); + const I = (V0 - VC) / R; + + // 6) Draw current arrow on top wire (direction: left→right). Length proportional to I. + const arrowLength = Math.min(I * 1000, 100); // scale for visibility + if (running) { + ctx.strokeStyle = "#DC2626"; + ctx.fillStyle = "#DC2626"; + ctx.lineWidth = 2; + // arrow shaft + ctx.beginPath(); + ctx.moveTo(leftX + 20, centerY - 20); + ctx.lineTo(leftX + 20 + arrowLength, centerY - 20); + ctx.stroke(); + // arrowhead + const tipX = leftX + 20 + arrowLength; + const tipY = centerY - 20; + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo(tipX - 8, tipY - 5); + ctx.lineTo(tipX - 8, tipY + 5); + ctx.closePath(); + ctx.fill(); + } + + // 7) Update numeric readouts + readoutT.textContent = t.toFixed(2); + readoutVc.textContent = VC.toFixed(2); + readoutI.textContent = I.toFixed(3); + readoutTau.textContent = tau.toFixed(3); + } + + // Draw a zig-zag resistor between (x1,y) and (x2,y) + function drawResistor(x1, y, x2, y2) { + const totalLength = x2 - x1; + const peaks = 6; + const segment = totalLength / (peaks * 2); + ctx.strokeStyle = "#555"; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(x1, y); + for (let i = 0; i < peaks * 2; i++) { + const dx = x1 + segment * (i + 1); + const dy = y + (i % 2 === 0 ? -10 : 10); + ctx.lineTo(dx, dy); + } + ctx.lineTo(x2, y); + ctx.stroke(); + } + + // ==== 4. Slider Handlers ==== + VSlider.addEventListener("input", () => { + V0 = parseFloat(VSlider.value); + VValue.textContent = V0.toFixed(1); + // Update chart Y‐axis max + vcChart.options.scales.y.suggestedMax = V0; + vcChart.update("none"); + if (!running) { + drawCircuit(0); + } + }); + + RSlider.addEventListener("input", () => { + R = parseFloat(RSlider.value); + RValue.textContent = R.toFixed(0); + tau = R * C; + readoutTau.textContent = tau.toFixed(3); + if (!running) { + drawCircuit(0); + } + }); + + CSlider.addEventListener("input", () => { + C = parseFloat(CSlider.value) * 1e-6; + CValue.textContent = (C * 1e6).toFixed(0); + tau = R * C; + readoutTau.textContent = tau.toFixed(3); + if (!running) { + drawCircuit(0); + } + }); + + // ==== 5. Animation Loop & Launch ==== + function step(timestamp) { + if (!t0) t0 = timestamp; + const elapsed = (timestamp - t0) / 1000; // seconds + if (elapsed >= maxTime) { + drawCircuit(maxTime); + revealQuiz(); + return; + } + drawCircuit(elapsed); + + // Update graphs + // Vc(t) + const VC = V0 * (1 - Math.exp(-elapsed / tau)); + vcChart.data.labels.push(elapsed.toFixed(2)); + vcChart.data.datasets[0].data.push(VC.toFixed(2)); + vcChart.update("none"); + + // I(t) + const I = (V0 - VC) / R; + iChart.data.labels.push(elapsed.toFixed(2)); + iChart.data.datasets[0].data.push(I.toFixed(3)); + iChart.update("none"); + + animId = requestAnimationFrame(step); + } + + startBtn.addEventListener("click", () => { + // Disable controls + VSlider.disabled = true; + RSlider.disabled = true; + CSlider.disabled = true; + startBtn.disabled = true; + stopBtn.disabled = false; + resetBtn.disabled = true; + + tau = R * C; + maxTime = 5 * tau; + t0 = null; + + // Clear graphs + vcChart.data.labels = []; + vcChart.data.datasets[0].data = []; + vcChart.update("none"); + iChart.data.labels = []; + iChart.data.datasets[0].data = []; + iChart.update("none"); + + running = true; + animId = requestAnimationFrame(step); + }); + + stopBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + stopBtn.disabled = true; + startBtn.disabled = false; + resetBtn.disabled = false; + }); + + resetBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + t0 = null; + // Re-enable controls + VSlider.disabled = false; + RSlider.disabled = false; + CSlider.disabled = false; + startBtn.disabled = false; + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.add("hidden"); + + // Clear graphs + vcChart.data.labels = []; + vcChart.data.datasets[0].data = []; + vcChart.update("none"); + iChart.data.labels = []; + iChart.data.datasets[0].data = []; + iChart.update("none"); + + drawCircuit(0); + }); + + // ==== 6. Quiz Reveal ==== + function revealQuiz() { + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.remove("hidden"); + } + + // ==== 7. Initial Draw ==== + tau = R * C; + readoutTau.textContent = tau.toFixed(3); + drawCircuit(0); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/physics_inclined.js b/web/virtual_lab/static/virtual_lab/js/physics_inclined.js new file mode 100644 index 0000000..576a8dc --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/physics_inclined.js @@ -0,0 +1,497 @@ +// web/virtual_lab/static/virtual_lab/js/physics_inclined.js + +document.addEventListener("DOMContentLoaded", () => { + // ==== 1. Tutorial Overlay Logic ==== + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + const steps = [ + ["Drag the block along the ramp to set its starting position."], + ["Adjust angle, friction, and mass to see how physics changes."], + ["Click “Launch” and watch live readouts, force vectors, and energy bars."], + ["Observe the real-time Position vs Time graph and answer the quiz!"] + ]; + + let currentStep = 0; + function showStep(i) { + stepNumberElem.textContent = i + 1; + stepList.innerHTML = ""; + steps[i].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = i === 0; + nextBtn.textContent = i === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.style.display = "none"; + enableControls(); + } + }); + skipBtn.addEventListener("click", () => { + tutorialOverlay.style.display = "none"; + enableControls(); + }); + // ==== End Tutorial Overlay Logic ==== + + + // ==== 2. DOM References & State ==== + const canvas = document.getElementById("inclined-canvas"); + const ctx = canvas.getContext("2d"); + if(!ctx) + { + console.error("Failed to get 2D context for canvas"); + return; + } + + const angleSlider = document.getElementById("angle-slider"); + const angleValue = document.getElementById("angle-value"); + const frictionSlider = document.getElementById("friction-slider"); + const frictionValue = document.getElementById("friction-value"); + const massSlider = document.getElementById("mass-slider"); + const massValue = document.getElementById("mass-value"); + + const startBtn = document.getElementById("start-inclined"); + const stopBtn = document.getElementById("stop-inclined"); + const resetBtn = document.getElementById("reset-inclined"); + + const quizDiv = document.getElementById("postlab-quiz"); + + const readoutS = document.getElementById("readout-s"); + const readoutV = document.getElementById("readout-v"); + const readoutA = document.getElementById("readout-a"); + const readoutPE = document.getElementById("readout-pe"); + const readoutKE = document.getElementById("readout-ke"); + + const barPE = document.getElementById("bar-pe"); + const barKE = document.getElementById("bar-ke"); + + const positionCtx = document.getElementById("position-chart").getContext("2d"); + + // Physical constants & scaling + const G = 9.81; // m/s² + const pxToM = 0.01; // 1 px = 0.01 m + const mToPx = 1 / pxToM; // inverse + const rampPx = 300; // 300 px → 3 m + const rampLen = rampPx * pxToM; // 3 m + + // Canvas dimensions & ramp origin + const W = canvas.width; // 600 px + const H = canvas.height; // 400 px + const originX = 50; // left margin + const originY = H - 50; // bottom margin + + // Experiment state + let alphaDeg = Number.parseFloat(angleSlider.value); // initial angle + let alphaRad = (alphaDeg * Math.PI) / 180; + + let mu = Number.parseFloat(frictionSlider.value); // friction coefficient + let mass = Number.parseFloat(massSlider.value); // mass in kg + + let aAcc = G * Math.sin(alphaRad) // net accel down-ramp + - mu * G * Math.cos(alphaRad); + + // Block state + let s = 0; // distance along plane (m) from top + let v = 0; // speed (m/s) + let t0 = null; // timestamp at launch + let animId = null; // requestAnimationFrame id + let running = false; // true while animating + + // Ramp geometry (recompute whenever angle changes) + let basePx, heightPx; + function updateRampGeometry() { + basePx = rampPx * Math.cos(alphaRad); + heightPx = rampPx * Math.sin(alphaRad); + } + updateRampGeometry(); + + // Chart.js: Position vs Time + const posData = { + labels: [], + datasets: [{ + label: "Position (m)", + data: [], + borderColor: "#1E40AF", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const posChart = new Chart(positionCtx, { + type: "line", + data: posData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "s (m)" } } + }, + plugins: { legend: { display: false } } + } + }); + + // Disable controls until tutorial ends + startBtn.disabled = true; + stopBtn.disabled = true; + resetBtn.disabled = true; + angleSlider.disabled = true; + frictionSlider.disabled = true; + massSlider.disabled = true; + + + // ==== 3. Enable Controls & Initial Draw ==== + function enableControls() { + startBtn.disabled = false; + resetBtn.disabled = false; + angleSlider.disabled = false; + frictionSlider.disabled = false; + massSlider.disabled = false; + drawScene(); + } + + // Draw wedge, block, forces, and update UI + function drawScene() { + ctx.clearRect(0, 0, W, H); + + // --- 3a) Draw ramp as a 3D‐style wedge with gradient --- + const grad = ctx.createLinearGradient( + originX, originY - heightPx, + originX + basePx, originY + ); + grad.addColorStop(0, "#F3F4F6"); + grad.addColorStop(1, "#E5E7EB"); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(originX + basePx, originY); + ctx.lineTo(originX, originY - heightPx); + ctx.closePath(); + ctx.fill(); + + // Outline the triangle + ctx.strokeStyle = "#4B5563"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(originX + basePx, originY); + ctx.lineTo(originX, originY - heightPx); + ctx.closePath(); + ctx.stroke(); + + // --- 3b) Compute block’s pixel position on ramp --- + const topX = originX; + const topY = originY - heightPx; + const d_px = (s / rampLen) * rampPx; // px from top down the plane + const blockX = topX + d_px * Math.cos(alphaRad); + const blockY = topY + d_px * Math.sin(alphaRad); + + // Draw block as rotated square (12×12 px) + const blkSize = 12; + ctx.save(); + ctx.translate(blockX, blockY); + ctx.rotate(-alphaRad); + ctx.fillStyle = "#DC2626"; + ctx.fillRect(-blkSize / 2, -blkSize / 2, blkSize, blkSize); + ctx.strokeStyle = "#991B1B"; + ctx.lineWidth = 1; + ctx.strokeRect(-blkSize / 2, -blkSize / 2, blkSize, blkSize); + ctx.restore(); + + // --- 3c) Draw force vectors at block center --- + drawForceVectors(blockX, blockY); + + // --- 3d) Update numeric readouts --- + readoutS.textContent = s.toFixed(2); + readoutV.textContent = v.toFixed(2); + readoutA.textContent = aAcc.toFixed(2); + + const heightM = (rampLen - s) * Math.sin(alphaRad); + const PE = mass * G * heightM; + const KE = 0.5 * mass * v * v; + readoutPE.textContent = PE.toFixed(2); + readoutKE.textContent = KE.toFixed(2); + + // --- 3e) Update energy bars --- + const maxPE = mass * G * (rampLen * Math.sin(alphaRad)); + const peFrac = maxPE > 0 ? Math.min(PE / maxPE, 1) : 0; + const keFrac = maxPE > 0 ? Math.min(KE / maxPE, 1) : 0; + barPE.style.height = `${peFrac * 100}%`; + barKE.style.height = `${keFrac * 100}%`; + } + + // Draw mg sinα (red), mg cosα (blue), friction (yellow) at block pos + function drawForceVectors(cx, cy) { + ctx.save(); + ctx.translate(cx, cy); + + // 1) mg sin α (red) + const redLen = 30; // px + ctx.strokeStyle = "#DC2626"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(redLen * Math.cos(alphaRad), redLen * Math.sin(alphaRad)); + ctx.stroke(); + drawArrowhead(redLen * Math.cos(alphaRad), redLen * Math.sin(alphaRad), alphaRad); + + // 2) mg cos α (blue) → perpendicular + const blueLen = 20; + const perpAngle = alphaRad - Math.PI / 2; + ctx.strokeStyle = "#3B82F6"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(blueLen * Math.cos(perpAngle), blueLen * Math.sin(perpAngle)); + ctx.stroke(); + drawArrowhead(blueLen * Math.cos(perpAngle), blueLen * Math.sin(perpAngle), perpAngle); + + // 3) friction (yellow) uphill (opposite motion) if μ>0 + if (mu > 0) { + const yellowLen = 25; + const fricAngle = alphaRad + Math.PI; // uphill + ctx.strokeStyle = "#FBBF24"; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(yellowLen * Math.cos(fricAngle), yellowLen * Math.sin(fricAngle)); + ctx.stroke(); + drawArrowhead(yellowLen * Math.cos(fricAngle), yellowLen * Math.sin(fricAngle), fricAngle); + } + + ctx.restore(); + } + + // Draw an arrowhead at (x,y), pointing angle θ + function drawArrowhead(x, y, θ) { + const size = 6; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(θ); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(-size, size / 2); + ctx.lineTo(-size, -size / 2); + ctx.closePath(); + ctx.fillStyle = ctx.strokeStyle; + ctx.fill(); + ctx.restore(); + } + + + // ==== 4. Slider Change Handlers ==== + angleSlider.addEventListener("input", () => { + alphaDeg = Number.parseFloat(angleSlider.value); + alphaRad = (alphaDeg * Math.PI) / 180; + angleValue.textContent = `${alphaDeg}°`; + updateDynamics(); + updateRampGeometry(); + if (!running) { + s = 0; + drawScene(); + } + }); + + frictionSlider.addEventListener("input", () => { + mu = Number.parseFloat(frictionSlider.value); + frictionValue.textContent = mu.toFixed(2); + updateDynamics(); + if (!running) drawScene(); + }); + + massSlider.addEventListener("input", () => { + mass = Number.parseFloat(massSlider.value); + massValue.textContent = `${mass.toFixed(1)} kg`; + if (!running) drawScene(); + }); + + function updateDynamics() { + aAcc = G * Math.sin(alphaRad) - mu * G * Math.cos(alphaRad); + } + + + // ==== Helper: Map MouseEvent → Canvas Coordinates ==== + function getMousePos(evt) { + const rect = canvas.getBoundingClientRect(); + // How many canvas pixels correspond to one CSS px? + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + return { + x: (evt.clientX - rect.left) * scaleX, + y: (evt.clientY - rect.top) * scaleY + }; + } + + + // ==== 5. Drag-to-Place Logic (with scaling) ==== + let isDragging = false; + + canvas.addEventListener("mousedown", (e) => { + const mouse = getMousePos(e); + + // Block’s pixel position (in internal 600×400 coordinate): + const topX = originX; + const topY = originY - heightPx; + const d_px = (s / rampLen) * rampPx; + const bx = topX + d_px * Math.cos(alphaRad); + const by = topY + d_px * Math.sin(alphaRad); + + // If click is within ~10 px (in canvas coords) of the block, start dragging: + if (Math.hypot(mouse.x - bx, mouse.y - by) < 10) { + isDragging = true; + stopAnimation(); + } + }); + + canvas.addEventListener("mousemove", (e) => { + if (!isDragging) return; + const mouse = getMousePos(e); + + // Project onto the ramp line to find new s: + const topX = originX; + const topY = originY - heightPx; + const dx = mouse.x - topX; + const dy = mouse.y - topY; + // t* = ( (mx-topX)cosα + (my-topY)sinα ), in px along ramp + const tStar = dx * Math.cos(alphaRad) + dy * Math.sin(alphaRad); + const tClamped = Math.min(Math.max(tStar, 0), rampPx); + + s = (tClamped / rampPx) * rampLen; // convert px→m + drawScene(); + }); + + canvas.addEventListener("mouseup", () => { + if (isDragging) isDragging = false; + }); + + + // ==== 6. Animation Loop & Launch (with scaled coords) ==== + let s0 = 0; + + function step(timestamp) { + if (!t0) { + t0 = timestamp; + v = 0; + } + const t = (timestamp - t0) / 1000; // seconds + const sNew = s0 + 0.5 * aAcc * t * t; + + // If net acceleration ≤ 0, it will not move + if (aAcc <= 0) { + cancelAnimationFrame(animId); + running = false; + stopBtn.disabled = true; + startBtn.disabled = false; + resetBtn.disabled = false; + return; + } + + if (sNew >= rampLen) { + s = rampLen; + v = aAcc * t; + drawScene(); + revealQuiz(); + return; + } + + s = sNew; + v = aAcc * t; + drawScene(); + + // Update Position vs Time chart + posChart.data.labels.push(t.toFixed(2)); + posChart.data.datasets[0].data.push(s.toFixed(2)); + posChart.update("none"); + + animId = requestAnimationFrame(step); + } + + startBtn.addEventListener("click", () => { + angleSlider.disabled = true; + frictionSlider.disabled = true; + massSlider.disabled = true; + startBtn.disabled = true; + stopBtn.disabled = false; + resetBtn.disabled = true; + + s0 = s; + v = 0; + t0 = null; + running = true; + + // Clear chart + posChart.data.labels = []; + posChart.data.datasets[0].data = []; + posChart.update("none"); + + animId = requestAnimationFrame(step); + }); + + function stopAnimation() { + if (animId) { + cancelAnimationFrame(animId); + running = false; + stopBtn.disabled = true; + startBtn.disabled = false; + resetBtn.disabled = false; + } + } + + stopBtn.addEventListener("click", stopAnimation); + + resetBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + s = 0; + v = 0; + t0 = null; + angleSlider.disabled = false; + frictionSlider.disabled = false; + massSlider.disabled = false; + startBtn.disabled = false; + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.add("hidden"); + + // Clear chart + posChart.data.labels = []; + posChart.data.datasets[0].data = []; + posChart.update("none"); + + drawScene(); + }); + + + // ==== 7. Quiz Reveal ==== + function revealQuiz() { + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.remove("hidden"); + } + + + // ==== 8. Initial Draw ==== + drawScene(); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/physics_mass_spring.js b/web/virtual_lab/static/virtual_lab/js/physics_mass_spring.js new file mode 100644 index 0000000..e04f881 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/physics_mass_spring.js @@ -0,0 +1,363 @@ +// web/virtual_lab/static/virtual_lab/js/physics_mass_spring.js + +document.addEventListener("DOMContentLoaded", () => { + // ==== 1. Tutorial Overlay Logic ==== + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + const steps = [ + ["Drag the mass horizontally to set its initial displacement \(A\)."], + ["Adjust the spring constant \(k\) and mass \(m\)."], + ["Click “Start” to watch \(x(t) = A \cos(\omega t)\) with \(\omega = \sqrt{k/m}\)."], + ["Observe the Position vs. Time graph and answer the quiz!"] + ]; + + let currentStep = 0; + function showStep(i) { + stepNumberElem.textContent = i + 1; + stepList.innerHTML = ""; + steps[i].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = i === 0; + nextBtn.textContent = i === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.style.display = "none"; + enableControls(); + } + }); + skipBtn.addEventListener("click", () => { + tutorialOverlay.style.display = "none"; + enableControls(); + }); + // ==== End Tutorial Overlay Logic ==== + + + // ==== 2. DOM References & State ==== + const canvas = document.getElementById("mass-spring-canvas"); + const ctx = canvas.getContext("2d"); + + const kSlider = document.getElementById("k-slider"); + const kValue = document.getElementById("k-value"); + const mSlider = document.getElementById("m-slider"); + const mValue = document.getElementById("m-value"); + const ASlider = document.getElementById("A-slider"); + const AValue = document.getElementById("A-value"); + + const startBtn = document.getElementById("start-mass-spring"); + const stopBtn = document.getElementById("stop-mass-spring"); + const resetBtn = document.getElementById("reset-mass-spring"); + + const quizDiv = document.getElementById("postlab-quiz"); + + const readoutT = document.getElementById("readout-t"); + const readoutX = document.getElementById("readout-x"); + const readoutV = document.getElementById("readout-v"); + const readoutA = document.getElementById("readout-a"); + const readoutPE = document.getElementById("readout-pe"); + const readoutKE = document.getElementById("readout-ke"); + + const barPE = document.getElementById("bar-pe"); + const barKE = document.getElementById("bar-ke"); + + const positionCtx = document.getElementById("position-chart").getContext("2d"); + + // Physical constants & scaling + const pxToM = 0.01; // 1 px = 0.01 m + const mToPx = 1 / pxToM; // invert + const equilibriumX = canvas.width / 2; // center pixel is equilibrium (x=0) + + // DOM Element–related state + let k = Number.parseFloat(kSlider.value); // N/m + let m = Number.parseFloat(mSlider.value); // kg + let A = Number.parseFloat(ASlider.value); // m + let omega = Math.sqrt(k / m); // rad/s + + // Simulation state + let t0 = null; // timestamp at “Start” + let animId = null; // requestAnimationFrame ID + let running = false; // true while animating + + // Derived state for this run + let maxT = (2 * Math.PI) / omega; // one full period + + // Chart.js: Position vs Time + const posData = { + labels: [], + datasets: [{ + label: "x(t) (m)", + data: [], + borderColor: "#1E40AF", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const posChart = new Chart(positionCtx, { + type: "line", + data: posData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "x (m)" } } + }, + plugins: { legend: { display: false } } + } + }); + + // Disable controls until tutorial ends + startBtn.disabled = true; + stopBtn.disabled = true; + resetBtn.disabled = true; + kSlider.disabled = true; + mSlider.disabled = true; + ASlider.disabled = true; + + + // ==== 3. Enable Controls & Initial Draw ==== + function enableControls() { + startBtn.disabled = false; + resetBtn.disabled = false; + kSlider.disabled = false; + mSlider.disabled = false; + ASlider.disabled = false; + drawScene(0); // draw mass at initial x = +A + } + + // Draw mass‐spring system at time t (in seconds) + function drawScene(t) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // 3a) Parameters at current run + const kVal = k; + const mVal = m; + const omegaVal = Math.sqrt(kVal / mVal); + + // 3b) Position x(t) in meters: x = A cos(omega t) + const x_m = A * Math.cos(omegaVal * t); + const v_m = -A * omegaVal * Math.sin(omegaVal * t); + const a_m = -A * omegaVal * omegaVal * Math.cos(omegaVal * t); + + // 3c) Convert x_m to pixel: 0 m → equilibriumX px, positive → right + const x_px = equilibriumX + x_m * mToPx; + const massY = canvas.height / 2; // vertical position for the block + const massSize = 20; // 20×20 px block + + // Draw horizontal line (spring baseline) + ctx.strokeStyle = "#4B5563"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, massY); + ctx.lineTo(canvas.width, massY); + ctx.stroke(); + + // Draw spring “coils” from left wall (10 px) to mass position + drawSpring(10, massY, x_px - massSize / 2, massY); + + // Draw mass as a square + ctx.fillStyle = "#DC2626"; + ctx.fillRect(x_px - massSize / 2, massY - massSize / 2, massSize, massSize); + ctx.strokeStyle = "#991B1B"; + ctx.strokeRect(x_px - massSize / 2, massY - massSize / 2, massSize, massSize); + + // 3d) Update numeric readouts + readoutT.textContent = t.toFixed(2); + readoutX.textContent = x_m.toFixed(2); + readoutV.textContent = v_m.toFixed(2); + readoutA.textContent = a_m.toFixed(2); + + const PE = 0.5 * kVal * x_m * x_m; // ½ k x² + const KE = 0.5 * mVal * v_m * v_m; // ½ m v² + readoutPE.textContent = PE.toFixed(2); + readoutKE.textContent = KE.toFixed(2); + + // 3f) Update energy bars + // Max total energy = ½ k A² + const Emax = 0.5 * kVal * A * A; + const peFrac = Emax > 0 ? Math.min(PE / Emax, 1) : 0; + const keFrac = Emax > 0 ? Math.min(KE / Emax, 1) : 0; + barPE.style.height = `${peFrac * 100}%`; + barKE.style.height = `${keFrac * 100}%`; + } + + // Draw a coiled spring from (x1,y1) to (x2,y2) + function drawSpring(x1, y1, x2, y2) { + const totalLength = x2 - x1; + const coilCount = 12; // number of loops + const coilSpacing = totalLength / (coilCount + 1); + const amplitude = 10; // amplitude of the sine wave (px) + + ctx.strokeStyle = "#4A5568"; + ctx.lineWidth = 2; + ctx.beginPath(); + + // Start at x1,y1 + ctx.moveTo(x1, y1); + + // For each coil, draw a half sine wave segment + for (let i = 1; i <= coilCount; i++) { + const cx = x1 + coilSpacing * i; + const phase = (i % 2 === 0) ? Math.PI : 0; // alternate phase for up/down + const px = cx; + const py = y1 + Math.sin(phase) * amplitude; + + // Instead of a straight line, approximate with a small sine curve + const steps = 10; // subdivide each coil into 10 segments + for (let j = 0; j <= steps; j++) { + const t = (j / steps); + const sx = x1 + coilSpacing * (i - 1) + t * coilSpacing; + const angle = ((i - 1 + t) * Math.PI); + const sy = y1 + Math.sin(angle) * amplitude * ((i - 1 + t) % 2 === 0 ? 1 : -1); + ctx.lineTo(sx, sy); + } + } + + // Finally connect to x2,y2 + ctx.lineTo(x2, y2); + ctx.stroke(); + } + + + // ==== 4. Slider Change Handlers ==== + kSlider.addEventListener("input", () => { + k = Number.parseFloat(kSlider.value); + kValue.textContent = k.toFixed(0); + omega = Math.sqrt(k / m); + maxT = (2 * Math.PI) / omega; + if (!running) { + drawScene(0); + } + }); + + mSlider.addEventListener("input", () => { + m = Number.parseFloat(mSlider.value); + mValue.textContent = `${m.toFixed(1)} kg`; + omega = Math.sqrt(k / m); + maxT = (2 * Math.PI) / omega; + if (!running) { + drawScene(0); + } + }); + + ASlider.addEventListener("input", () => { + A = Number.parseFloat(ASlider.value); + AValue.textContent = A.toFixed(2); + if (!running) { + drawScene(0); + } + }); + + + // ==== 5. Animation Loop & Launch ==== + function step(timestamp) { + if (!t0) { + t0 = timestamp; + } + let elapsed = (timestamp - t0) / 1000; // seconds since start + if (elapsed >= maxT) { + // One full oscillation complete → reveal quiz + drawScene(maxT); + revealQuiz(); + return; + } + drawScene(elapsed); + + // Update Position vs Time chart + posChart.data.labels.push(elapsed.toFixed(2)); + const x_m = A * Math.cos(omega * elapsed); + posChart.data.datasets[0].data.push(x_m.toFixed(2)); + posChart.update("none"); + + animId = requestAnimationFrame(step); + } + + startBtn.addEventListener("click", () => { + // Disable controls while animating + kSlider.disabled = true; + mSlider.disabled = true; + ASlider.disabled = true; + startBtn.disabled = true; + stopBtn.disabled = false; + resetBtn.disabled = true; + + // Prepare for launch + omega = Math.sqrt(k / m); + maxT = (2 * Math.PI) / omega; + t0 = null; + + // Clear chart + posChart.data.labels = []; + posChart.data.datasets[0].data = []; + posChart.update("none"); + + running = true; + animId = requestAnimationFrame(step); + }); + + stopBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + stopBtn.disabled = true; + startBtn.disabled = false; + resetBtn.disabled = false; + }); + + resetBtn.addEventListener("click", () => { + if (animId) cancelAnimationFrame(animId); + running = false; + t0 = null; + // Re-enable sliders + kSlider.disabled = false; + mSlider.disabled = false; + ASlider.disabled = false; + startBtn.disabled = false; + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.add("hidden"); + + // Clear chart + posChart.data.labels = []; + posChart.data.datasets[0].data = []; + posChart.update("none"); + + // Redraw at t=0 + drawScene(0); + }); + + + // ==== 6. Quiz Reveal ==== + function revealQuiz() { + stopBtn.disabled = true; + resetBtn.disabled = false; + quizDiv.classList.remove("hidden"); + } + + + // ==== 7. Initial Draw ==== + drawScene(0); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/physics_pendulum.js b/web/virtual_lab/static/virtual_lab/js/physics_pendulum.js new file mode 100644 index 0000000..48874e4 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/physics_pendulum.js @@ -0,0 +1,396 @@ +// web/virtual_lab/static/virtual_lab/js/physics_pendulum.js + +document.addEventListener("DOMContentLoaded", () => { + // -------- Tutorial Logic with Animated Steps -------- // + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + // Each step is an array of bullet‐point strings + const steps = [ + ["A pendulum consists of a mass (bob) attached to a string, swinging under gravity."], + [ + "The length (L) of the string determines how fast it swings.", + "Longer pendulum → slower oscillation; shorter → faster." + ], + [ + "The angular frequency ω = √(g / L), where g ≈ 9.81 m/s².", + "ω tells us how quickly the pendulum oscillates." + ], + [ + "The motion follows θ(t) = θ₀ · cos(ω t),", + "where θ₀ is the initial amplitude in radians." + ], + [ + "After one period T = 2π / ω, the pendulum returns to its start.", + "Click 'Next' to begin the experiment!" + ] + ]; + + let currentStep = 0; + + function showStep(index) { + stepNumberElem.textContent = index + 1; + stepList.innerHTML = ""; + steps[index].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = index === 0; + nextBtn.textContent = index === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.classList.add("hidden"); + enableControls(); + } + }); + + skipBtn.addEventListener("click", () => { + tutorialOverlay.classList.add("hidden"); + enableControls(); + }); + // -------- End Animated Tutorial Logic -------- // + + // -------- Simulation, Chart, Energy, Trail & Readouts -------- // + const canvas = document.getElementById("pendulum-canvas"); + const ctx = canvas.getContext("2d"); + const lengthSlider = document.getElementById("length-slider"); + const lengthValue = document.getElementById("length-value"); + const startButton = document.getElementById("start-pendulum"); + const stopButton = document.getElementById("stop-pendulum"); + const postlabQuiz = document.getElementById("postlab-quiz"); + + // Energy bars + const peBar = document.getElementById("pe-bar"); + const keBar = document.getElementById("ke-bar"); + + // Numeric readouts + const timeReadout = document.getElementById("time-readout"); + const angleReadout = document.getElementById("angle-readout"); + const speedReadout = document.getElementById("speed-readout"); + + // Chart.js mini‐graph + const chartCanvas = document.getElementById("angle-chart").getContext("2d"); + + // Physical constants and state + const g = 9.81; // m/s² + const originX = canvas.width / 2; + const originY = 50; // pivot-point y-coordinate + const pixelsPerMeter = 100; // 1 m → 100 px + const bobRadius = 15; // px + + let L = Number.parseFloat(lengthSlider.value); // length in meters + let omega = Math.sqrt(g / L); // angular frequency + let theta0 = 0.3; // initial amplitude (rad) + let currentAngle = theta0; + let animationId = null; + let startTime = null; + + // For drag-and-release + let isDragging = false; + let dragAngle = 0; + + // Initialize Chart.js + const angleChart = new Chart(chartCanvas, { + type: "line", + data: { + labels: [], + datasets: [{ + label: "θ(t) (rad)", + data: [], + borderColor: "#FF6633", + borderWidth: 2, + fill: false, + pointRadius: 0, + }] + }, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "Time (s)" } }, + y: { title: { display: true, text: "Angle (rad)" }, min: -theta0, max: theta0 } + }, + plugins: { legend: { display: false } } + } + }); + + // Draw pendulum with a "trail" effect + function drawPendulum(angle, length) { + ctx.fillStyle = "rgba(255, 255, 255, 0.1)"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const r = length * pixelsPerMeter; + const bobX = originX + r * Math.sin(angle); + const bobY = originY + r * Math.cos(angle); + + // Draw rod + ctx.beginPath(); + ctx.moveTo(originX, originY); + ctx.lineTo(bobX, bobY); + ctx.strokeStyle = "#333"; + ctx.lineWidth = 2; + ctx.stroke(); + + // Draw bob + ctx.beginPath(); + ctx.arc(bobX, bobY, bobRadius, 0, 2 * Math.PI); + ctx.fillStyle = "#007BFF"; + ctx.fill(); + ctx.strokeStyle = "#0056b3"; + ctx.stroke(); + } + + // Convert mouse coords → angle from vertical + function computeAngleFromMouse(mouseX, mouseY, length) { + const dx = mouseX - originX; + const dy = mouseY - originY; + const r = length * pixelsPerMeter; + const dist = Math.hypot(dx, dy); + const scale = r / dist; + const px = dx * scale; + const py = dy * scale; + return Math.atan2(px, py); + } + + // Animation loop: updates everything each frame + function animatePendulum(timestamp) { + if (!startTime) startTime = timestamp; + const elapsedSec = (timestamp - startTime) / 1000; // ms → s + + // Angle: θ(t) = θ₀ cos(ω t) + const angle = theta0 * Math.cos(omega * elapsedSec); + currentAngle = angle; + + // 1) Draw pendulum with trail + drawPendulum(angle, L); + + // 2) Update mini-graph + if (angleChart.data.labels.length > 100) { + angleChart.data.labels.shift(); + angleChart.data.datasets[0].data.shift(); + } + angleChart.data.labels.push(elapsedSec.toFixed(2)); + angleChart.data.datasets[0].data.push(angle); + angleChart.update("none"); + + // 3) Compute energies (m = 1 kg) + const h = L * (1 - Math.cos(angle)); // height above bottom + const pe = g * h; // PE = m g h (m=1) + // Velocity: v = L * (dθ/dt) = L * (−θ₀ ω sin(ωt)) + const v = -L * theta0 * omega * Math.sin(omega * elapsedSec); + const ke = 0.5 * v * v; // KE = ½ m v² (m=1) + const E = pe + ke; + const pePct = E ? (pe / E) * 100 : 0; + const kePct = E ? (ke / E) * 100 : 0; + peBar.style.width = `${pePct.toFixed(1)}%`; + keBar.style.width = `${kePct.toFixed(1)}%`; + + // 4) Update numeric readouts + timeReadout.textContent = elapsedSec.toFixed(2); + angleReadout.textContent = (angle * (180 / Math.PI)).toFixed(1); + speedReadout.textContent = Math.abs(v).toFixed(2); + + // 5) Reveal quiz after one full period + const period = (2 * Math.PI) / omega; + if (elapsedSec >= period && postlabQuiz.classList.contains("hidden")) { + postlabQuiz.classList.remove("hidden"); + } + + animationId = requestAnimationFrame(animatePendulum); + } + + // Enable controls after tutorial ends + function enableControls() { + startButton.disabled = false; + stopButton.disabled = false; + lengthSlider.disabled = false; + currentAngle = theta0; + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawPendulum(currentAngle, L); + } + + // Initially disable controls + startButton.disabled = true; + stopButton.disabled = true; + lengthSlider.disabled = true; + + // Update length L & ω on slider input + lengthSlider.addEventListener("input", () => { + L = Number.parseFloat(lengthSlider.value); + lengthValue.textContent = `${L.toFixed(1)} m`; + omega = Math.sqrt(g / L); + // Adjust chart y-axis + angleChart.options.scales.y.min = -theta0; + angleChart.options.scales.y.max = theta0; + angleChart.update("none"); + + if (!isDragging && !animationId) { + currentAngle = theta0; + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawPendulum(currentAngle, L); + } + }); + + // Start button: reset chart, energy bars, trail + startButton.addEventListener("click", () => { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + postlabQuiz.classList.add("hidden"); + } + + // Clear canvas fully + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Reset chart + angleChart.data.labels = []; + angleChart.data.datasets[0].data = []; + angleChart.update("none"); + + // Reset energy bars + peBar.style.width = "0%"; + keBar.style.width = "0%"; + + // Reset numeric readouts + timeReadout.textContent = "0.00"; + angleReadout.textContent = (theta0 * (180 / Math.PI)).toFixed(1); + speedReadout.textContent = "0.00"; + + // Use currentAngle (maybe from drag) as θ₀ + theta0 = currentAngle; + angleChart.options.scales.y.min = -theta0; + angleChart.options.scales.y.max = theta0; + angleChart.update("none"); + + startTime = null; + animationId = requestAnimationFrame(animatePendulum); + }); + + // Stop button: cancel animation + stopButton.addEventListener("click", () => { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawPendulum(currentAngle, L); + }); + + // -------- Drag & Release Logic (Mouse + Touch) -------- // + + // Helper function to get coordinates from mouse or touch event + function getEventCoords(e) { + const rect = canvas.getBoundingClientRect(); + if (e.type.startsWith('touch')) { + return { + x: e.touches[0].clientX - rect.left, + y: e.touches[0].clientY - rect.top + }; + } else { + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + } + } + + // Helper function to start dragging + function startDrag(e) { + if (animationId) { + cancelAnimationFrame(animationId); + animationId = null; + } + + const coords = getEventCoords(e); + const r = L * pixelsPerMeter; + const bobX = originX + r * Math.sin(currentAngle); + const bobY = originY + r * Math.cos(currentAngle); + const distToBob = Math.hypot(coords.x - bobX, coords.y - bobY); + + if (distToBob <= bobRadius + 3) { + isDragging = true; + dragAngle = currentAngle; + e.preventDefault(); // Prevent default for touch events + } + } + + // Helper function to handle dragging + function handleDrag(e) { + if (!isDragging) return; + + const coords = getEventCoords(e); + dragAngle = computeAngleFromMouse(coords.x, coords.y, L); + currentAngle = dragAngle; + ctx.clearRect(0, 0, canvas.width, canvas.height); + drawPendulum(currentAngle, L); + + // Update numeric readouts during drag + timeReadout.textContent = "0.00"; + angleReadout.textContent = (currentAngle * (180 / Math.PI)).toFixed(1); + speedReadout.textContent = "0.00"; + + // Reset energy bars (PE only) + const h = L * (1 - Math.cos(currentAngle)); + const pe = g * h; + const total = pe; + peBar.style.width = total ? `${((pe / total) * 100).toFixed(1)}%` : "0%"; + keBar.style.width = "0%"; + + e.preventDefault(); // Prevent scrolling on touch + } + + // Helper function to end dragging + function endDrag() { + if (!isDragging) return; + isDragging = false; + theta0 = dragAngle; + angleChart.options.scales.y.min = -theta0; + angleChart.options.scales.y.max = theta0; + angleChart.update("none"); + + startTime = null; + animationId = requestAnimationFrame(animatePendulum); + } + + // Mouse events + canvas.addEventListener("mousedown", startDrag); + canvas.addEventListener("mousemove", handleDrag); + canvas.addEventListener("mouseup", endDrag); + canvas.addEventListener("mouseleave", endDrag); + + // Touch events for mobile + canvas.addEventListener("touchstart", startDrag); + canvas.addEventListener("touchmove", handleDrag); + canvas.addEventListener("touchend", endDrag); + canvas.addEventListener("touchcancel", endDrag); + + // Prevent default drag behavior + canvas.addEventListener("dragstart", (e) => { + e.preventDefault(); + }); + + // -------- End Drag & Release Logic -------- // +}); diff --git a/web/virtual_lab/static/virtual_lab/js/physics_projectile.js b/web/virtual_lab/static/virtual_lab/js/physics_projectile.js new file mode 100644 index 0000000..a48e456 --- /dev/null +++ b/web/virtual_lab/static/virtual_lab/js/physics_projectile.js @@ -0,0 +1,412 @@ +// web/virtual_lab/static/virtual_lab/js/physics_projectile.js + +document.addEventListener("DOMContentLoaded", () => { + // -------- 1. Tutorial Overlay Logic (animated bullet points) -------- // + const tutorialOverlay = document.getElementById("tutorial-overlay"); + const stepNumberElem = document.getElementById("step-number"); + const stepList = document.getElementById("step-list"); + const prevBtn = document.getElementById("tutorial-prev"); + const nextBtn = document.getElementById("tutorial-next"); + const skipBtn = document.getElementById("tutorial-skip"); + + const steps = [ + ["Click-and-drag FROM the launch pad (white circle at bottom-left) to set initial speed and angle."], + [ + "Drag length sets speed (longer drag → higher speed), direction sets angle.", + "Release to start the simulation." + ], + [ + "Adjust gravity and wind sliders BEFORE dragging; they affect the predicted trajectory.", + "After release, the full path animates." + ], + [ + "During flight, small x/y axes are drawn on the ball, showing velocity components.", + "Watch the “y vs x” plot updating in real time." + ], + ["Click “Begin Experiment” when ready, then drag from the launch pad!"] + ]; + + let currentStep = 0; + function showStep(index) { + stepNumberElem.textContent = index + 1; + stepList.innerHTML = ""; + steps[index].forEach((text, idx) => { + const li = document.createElement("li"); + li.textContent = text; + li.className = "opacity-0 transition-opacity duration-500"; + stepList.appendChild(li); + setTimeout(() => { + li.classList.remove("opacity-0"); + li.classList.add("opacity-100"); + }, idx * 200); + }); + prevBtn.disabled = index === 0; + nextBtn.textContent = index === steps.length - 1 ? "Begin Experiment" : "Next"; + } + + showStep(currentStep); + prevBtn.addEventListener("click", () => { + if (currentStep > 0) { + currentStep--; + showStep(currentStep); + } + }); + nextBtn.addEventListener("click", () => { + if (currentStep < steps.length - 1) { + currentStep++; + showStep(currentStep); + } else { + tutorialOverlay.style.display = "none"; + enableControls(); + } + }); + skipBtn.addEventListener("click", () => { + tutorialOverlay.style.display = "none"; + enableControls(); + }); + // -------- End Tutorial Logic -------- // + + + // -------- 2. DOM References & State -------- // + const canvas = document.getElementById("projectile-canvas"); + const ctx = canvas.getContext("2d"); + + const trajectoryCanvas = document.getElementById("trajectory-chart").getContext("2d"); + + const gravitySlider = document.getElementById("gravity-slider"); + const gravityValue = document.getElementById("gravity-value"); + const windSlider = document.getElementById("wind-slider"); + const windValue = document.getElementById("wind-value"); + const resetButton = document.getElementById("reset-button"); + + const timeReadout = document.getElementById("time-readout"); + const xReadout = document.getElementById("x-readout"); + const yReadout = document.getElementById("y-readout"); + const vxReadout = document.getElementById("vx-readout"); + const vyReadout = document.getElementById("vy-readout"); + + let g = Number.parseFloat(gravitySlider.value); + let windAccel = Number.parseFloat(windSlider.value); + + let originY = canvas.height - 10; + let pixelsPerMeter = 10; + + let v0 = 0, thetaRad = 0, vx = 0, vy0 = 0; + let maxRange = 0, maxHeight = 0; + + // Stores trajectory points and velocities + let trajectoryPoints = []; // { x_m, y_m, vx_t, vy_t } + let currentFrame = 0; + let animationId = null; + + const trajData = { + labels: [], + datasets: [{ + label: "y vs x (m)", + data: [], + borderColor: "#3182CE", + borderWidth: 2, + fill: false, + pointRadius: 0 + }] + }; + const trajChart = new Chart(trajectoryCanvas, { + type: "line", + data: trajData, + options: { + animation: false, + scales: { + x: { title: { display: true, text: "x (m)" } }, + y: { title: { display: true, text: "y (m)" } } + }, + plugins: { legend: { display: false } } + } + }); + + gravitySlider.disabled = true; + windSlider.disabled = true; + resetButton.disabled = true; + + + // -------- 3. Enable Controls & Initial Drawing -------- // + function enableControls() { + gravitySlider.disabled = false; + windSlider.disabled = false; + resetButton.disabled = false; + drawScene(); + } + + function drawAxes() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.beginPath(); + ctx.moveTo(0, originY); + ctx.lineTo(canvas.width, originY); + ctx.strokeStyle = "#2D3748"; + ctx.lineWidth = 2; + ctx.stroke(); + } + + function drawLaunchPad() { + const padX = 10, padY = originY, padR = 8; + ctx.beginPath(); + ctx.arc(padX, padY, padR, 0, 2 * Math.PI); + ctx.fillStyle = "#FFFFFF"; + ctx.fill(); + ctx.strokeStyle = "#4A5568"; + ctx.lineWidth = 2; + ctx.stroke(); + } + + function drawScene() { + drawAxes(); + drawLaunchPad(); + } + + // -------- 4. Gravity & Wind Slider Handlers -------- // + gravitySlider.addEventListener("input", () => { + g = Number.parseFloat(gravitySlider.value); + gravityValue.textContent = g.toFixed(2); + }); + windSlider.addEventListener("input", () => { + windAccel = Number.parseFloat(windSlider.value); + windValue.textContent = windAccel.toFixed(2); + }); + + + // -------- 5. Aim-by-Drag Logic -------- // + let isAiming = false; + let aimStartX = 0, aimStartY = 0; + let aimCurrentX = 0, aimCurrentY = 0; + + canvas.addEventListener("mousedown", (e) => { + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const padX = 10, padY = originY, padR = 8; + const dist = Math.hypot(mouseX - padX, mouseY - padY); + if (dist <= padR + 4) { + isAiming = true; + aimStartX = padX; + aimStartY = padY; + aimCurrentX = mouseX; + aimCurrentY = mouseY; + + trajChart.data.labels = []; + trajChart.data.datasets[0].data = []; + trajChart.update("none"); + + timeReadout.textContent = "0.00"; + xReadout.textContent = "0.00"; + yReadout.textContent = "0.00"; + vxReadout.textContent = "0.00"; + vyReadout.textContent = "0.00"; + + drawScene(); + } + }); + + canvas.addEventListener("mousemove", (e) => { + if (!isAiming) return; + const rect = canvas.getBoundingClientRect(); + aimCurrentX = e.clientX - rect.left; + aimCurrentY = e.clientY - rect.top; + + drawScene(); + + ctx.beginPath(); + ctx.moveTo(aimStartX, aimStartY); + ctx.lineTo(aimCurrentX, aimCurrentY); + ctx.strokeStyle = "#DD6B20"; + ctx.lineWidth = 3; + ctx.setLineDash([5, 5]); + ctx.stroke(); + ctx.setLineDash([]); + }); + + canvas.addEventListener("mouseup", (e) => { + if (!isAiming) return; + isAiming = false; + + const dx_px = aimCurrentX - aimStartX; + const dy_px = aimCurrentY - aimStartY; + if (dx_px <= 0) { + drawScene(); + return; + } + + // Convert drag to meters using temporary scale: 1 px → 0.1 m + const tmpScale = 0.1; + const dx_m = dx_px * tmpScale; + const dy_m = (originY - aimCurrentY) * tmpScale; // invert y-axis + + v0 = Math.hypot(dx_m, dy_m); + thetaRad = Math.atan2(dy_m, dx_m); + vx = v0 * Math.cos(thetaRad); + vy0 = v0 * Math.sin(thetaRad); + + // Compute theoretical max range & height + maxRange = (v0 * v0 * Math.sin(2 * thetaRad)) / g; + maxHeight = (v0 * v0 * Math.sin(thetaRad) * Math.sin(thetaRad)) / (2 * g); + + // Recalculate pixelsPerMeter so full path fits + const marginX = 60, marginY = 60; + const availableWidth = canvas.width - marginX; + const availableHeight = originY - marginY; + const scaleX = availableWidth / (maxRange + 1); + const scaleY = availableHeight / (maxHeight + 1); + pixelsPerMeter = Math.min(scaleX, scaleY); + + // Build the discrete trajectory points + buildTrajectoryPoints(); + + // Clear and draw static full trajectory faintly + drawScene(); + drawStaticTrajectory(); + + // Start animation loop + currentFrame = 0; + if (animationId) cancelAnimationFrame(animationId); + animationId = requestAnimationFrame(animateBall); + }); + + + // -------- 6. Build Trajectory Points & Velocities -------- // + function buildTrajectoryPoints() { + trajectoryPoints = []; + const timeOfFlight = (2 * vy0) / g; + const steps = 200; + for (let i = 0; i <= steps; i++) { + const t = (i / steps) * timeOfFlight; + const x_m = vx * t + 0.5 * windAccel * t * t; + const y_m = vy0 * t - 0.5 * g * t * t; + if (y_m < 0) { + trajectoryPoints.push({ x_m, y_m: 0, vx_t: vx + windAccel * t, vy_t: 0 }); + break; + } + const vx_t = vx + windAccel * t; + const vy_t = vy0 - g * t; + trajectoryPoints.push({ x_m, y_m, vx_t, vy_t }); + } + } + + // -------- 7. Draw Static Full Trajectory (faint) -------- // + function drawStaticTrajectory() { + ctx.beginPath(); + trajectoryPoints.forEach((pt, idx) => { + const px = pt.x_m * pixelsPerMeter + 10; + const py = originY - pt.y_m * pixelsPerMeter; + if (idx === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.strokeStyle = "rgba(221, 107, 32, 0.3)"; // faint orange + ctx.lineWidth = 2; + ctx.setLineDash([]); + ctx.stroke(); + } + + // -------- 8. Animation Loop: Move Ball & Draw Axes-On-Ball -------- // + function animateBall() { + if (currentFrame >= trajectoryPoints.length) return; + + // Clear and redraw background & static path + drawScene(); + drawStaticTrajectory(); + + const pt = trajectoryPoints[currentFrame]; + const canvasX = pt.x_m * pixelsPerMeter + 10; + const canvasY = originY - pt.y_m * pixelsPerMeter; + + // Draw the ball + ctx.beginPath(); + ctx.arc(canvasX, canvasY, 6, 0, 2 * Math.PI); + ctx.fillStyle = "#E53E3E"; + ctx.fill(); + ctx.strokeStyle = "#9B2C2C"; + ctx.stroke(); + + // Draw small x/y axes on the ball, and show velocity components + drawAxesOnBall(canvasX, canvasY, pt.vx_t, pt.vy_t); + + // Update numeric readouts + const t = (currentFrame / (trajectoryPoints.length - 1)) * ((2 * vy0) / g); + timeReadout.textContent = t.toFixed(2); + xReadout.textContent = pt.x_m.toFixed(2); + yReadout.textContent = pt.y_m.toFixed(2); + vxReadout.textContent = pt.vx_t.toFixed(2); + vyReadout.textContent = pt.vy_t.toFixed(2); + + // Update live plot with just this point + trajChart.data.labels.push(pt.x_m.toFixed(2)); + trajChart.data.datasets[0].data.push(pt.y_m.toFixed(2)); + trajChart.update("none"); + + currentFrame++; + animationId = requestAnimationFrame(animateBall); + } + + function drawAxesOnBall(cx, cy, vx_t, vy_t) { + // Draw a small cross (x and y axes) centered on the ball + const axisLen = 12; // total length of each axis line + ctx.strokeStyle = "#000000"; + ctx.lineWidth = 1; + ctx.beginPath(); + // Horizontal axis line + ctx.moveTo(cx - axisLen / 2, cy); + ctx.lineTo(cx + axisLen / 2, cy); + // Vertical axis line + ctx.moveTo(cx, cy - axisLen / 2); + ctx.lineTo(cx, cy + axisLen / 2); + ctx.stroke(); + + // Now plot velocity components as small dots on those axes: + // Scale velocities so they fit within half-axis length + const vScale = 0.2; // 1 m/s → 0.2 px + let vx_px = vx_t * vScale; + let vy_px = vy_t * vScale; + // Clamp so dot stays on axis line + vx_px = Math.max(Math.min(vx_px, axisLen / 2), -axisLen / 2); + vy_px = Math.max(Math.min(vy_px, axisLen / 2), -axisLen / 2); + + // Draw x-velocity dot (blue) on horizontal axis + ctx.beginPath(); + ctx.arc(cx + vx_px, cy, 2.5, 0, 2 * Math.PI); + ctx.fillStyle = "#3182CE"; + ctx.fill(); + + // Draw y-velocity dot (green) on vertical axis + ctx.beginPath(); + ctx.arc(cx, cy - vy_px, 2.5, 0, 2 * Math.PI); + ctx.fillStyle = "#38A169"; + ctx.fill(); + } + + // -------- 9. Reset Handler -------- // + resetButton.addEventListener("click", () => { + if (animationId) cancelAnimationFrame(animationId); + trajectoryPoints = []; + currentFrame = 0; + trajChart.data.labels = []; + trajChart.data.datasets[0].data = []; + trajChart.update("none"); + + drawScene(); + + g = 9.81; + windAccel = 0; + gravitySlider.value = "9.81"; + windSlider.value = "0"; + gravityValue.textContent = "9.81"; + windValue.textContent = "0.00"; + + timeReadout.textContent = "0.00"; + xReadout.textContent = "0.00"; + yReadout.textContent = "0.00"; + vxReadout.textContent = "0.00"; + vyReadout.textContent = "0.00"; + }); + + + // -------- 10. Initial Draw -------- // + drawScene(); +}); diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/index.html b/web/virtual_lab/templates/virtual_lab/chemistry/index.html new file mode 100644 index 0000000..8c09f72 --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/chemistry/index.html @@ -0,0 +1,58 @@ +{# templates/virtual_lab/chemistry/index.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} +{% load i18n %} + +{% block virtual_lab_content %} + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/ph_indicator.html b/web/virtual_lab/templates/virtual_lab/chemistry/ph_indicator.html new file mode 100644 index 0000000..458a40f --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/chemistry/ph_indicator.html @@ -0,0 +1,58 @@ +{# templates/virtual_lab/chemistry/ph_indicator.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} +{% load i18n %} + +{% block virtual_lab_content %} +
+

{% trans "pH Indicator" %}

+
+ +
+ + + +
+ {% trans "Enter a pH value (0–14) and click Update to see the color change." %} +
+
+
+ +
+ + + +
+
+
+{% endblock %} +{% block extra_scripts %} + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/precipitation.html b/web/virtual_lab/templates/virtual_lab/chemistry/precipitation.html new file mode 100644 index 0000000..aca5027 --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/chemistry/precipitation.html @@ -0,0 +1,56 @@ +{# templates/virtual_lab/chemistry/precipitation.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} +{% load i18n %} + +{% block virtual_lab_content %} +
+

{% trans "Precipitation Reaction" %}

+
+ +
+ + + +
+ {% trans "Click Add Reagent to begin." %} +
+
+
+ +
+ + + + + + +
+
+
+{% endblock %} +{% block extra_scripts %} + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/reaction_rate.html b/web/virtual_lab/templates/virtual_lab/chemistry/reaction_rate.html new file mode 100644 index 0000000..5154fd6 --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/chemistry/reaction_rate.html @@ -0,0 +1,54 @@ +{# templates/virtual_lab/chemistry/reaction_rate.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} +{% load i18n %} + +{% block extra_head %}{# no extra CSS needed #}{% endblock %} +{% block virtual_lab_content %} +
+

{% trans "Reaction Rate" %}

+
+ +
+ + + +
+ {% trans "Elapsed Time:" %} + 0 {% trans "s" %} +
+
+ {% trans "Set the concentration and start to see the reaction proceed..." %} +
+
+ {# will display "Reaction Complete" at the end #} +
+
+ + +
+
+{% endblock %} +{% block extra_scripts %} + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/solubility.html b/web/virtual_lab/templates/virtual_lab/chemistry/solubility.html new file mode 100644 index 0000000..b8fd25e --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/chemistry/solubility.html @@ -0,0 +1,53 @@ +{# templates/virtual_lab/chemistry/solubility.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} +{% load i18n %} + +{% block virtual_lab_content %} +
+

{% trans "Solubility & Saturation" %}

+
+ +
+ + + +
+ {% trans "Dissolved:" %} + 0 g +
+
+ {% trans "Solution is unsaturated. Add more solute to test saturation." %} +
+
+ {# final property will appear here #} +
+
+ + +
+
+{% endblock %} +{% block extra_scripts %} + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/titration.html b/web/virtual_lab/templates/virtual_lab/chemistry/titration.html new file mode 100644 index 0000000..cda887c --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/chemistry/titration.html @@ -0,0 +1,78 @@ +{# templates/virtual_lab/chemistry/titration.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} +{% load i18n %} + +{% block extra_head %}{# no extra CSS needed #}{% endblock %} +{% block virtual_lab_content %} +
+

{% trans "Acid-Base Titration" %}

+
+ +
+ + + + + +
+ {% trans "Titrant Added:" %} + 0 mL +
+
+ {% trans "Adjust the controls and start titration to see hints here..." %} +
+
+ {# will display: “Solution is Acidic/Neutral/Basic” #} +
+
+ +
+ + +
+
+
+{% endblock %} +{% block extra_scripts %} + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/code_editor/code_editor.html b/web/virtual_lab/templates/virtual_lab/code_editor/code_editor.html new file mode 100644 index 0000000..882355c --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/code_editor/code_editor.html @@ -0,0 +1,82 @@ +{# templates/virtual_lab/code_editor/code_editor.html #} +{% extends 'virtual_lab/layout.html' %} + +{% load static %} + +{% block title %} + Code Editor – Alpha Science Lab +{% endblock title %} +{% block extra_head %} + + + +{% endblock extra_head %} +{% block virtual_lab_content %} +
+

Interactive Code Editor

+ +
print("Hello, world!")
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+

Output:

+

+    
+
+{% endblock virtual_lab_content %} +{% block extra_scripts %} + + +{% endblock extra_scripts %} diff --git a/web/virtual_lab/templates/virtual_lab/home.html b/web/virtual_lab/templates/virtual_lab/home.html new file mode 100644 index 0000000..159d14f --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/home.html @@ -0,0 +1,51 @@ +{# web/virtual_lab/templates/virtual_lab/home.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} + +{% block virtual_lab_content %} + +{% endblock virtual_lab_content %} diff --git a/web/virtual_lab/templates/virtual_lab/layout.html b/web/virtual_lab/templates/virtual_lab/layout.html new file mode 100644 index 0000000..efb38e5 --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/layout.html @@ -0,0 +1,77 @@ +{# web/virtual_lab/templates/virtual_lab/layout.html #} +{% extends "base.html" %} + +{% load static %} +{% load i18n %} + +{% block content %} + + + + {% block extra_scripts %} + {% endblock extra_scripts %} +{% endblock content %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/circuit.html b/web/virtual_lab/templates/virtual_lab/physics/circuit.html new file mode 100644 index 0000000..4bf278a --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/physics/circuit.html @@ -0,0 +1,157 @@ +{# web/virtual_lab/templates/virtual_lab/physics/circuit.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} + +{% block virtual_lab_content %} +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Basic Electrical Circuit

+

+ Build a simple RC circuit: a battery \(V_0\), a resistor \(R\), and a capacitor \(C\). Adjust \(V_0\), \(R\), and \(C\), then click “Start” to watch the capacitor charge. Observe real‐time \(V_C(t)\), \(I(t)\), and a live graph of capacitor voltage over time. After \(5\tau\), a quiz appears. +

+
+
+ +
+ +
+
+ + + 5.0 V +
+
+ + + 100 Ω +
+
+ + + 100 µF +
+
+ + + +
+
+ +
+ + +
+

+ Time: 0.00 s +

+

+ Voltage \(V_C\): 0.00 V +

+

+ Current \(I\): 0.00 A +

+

+ Time Constant \(\tau\): 0.01 s +

+
+
+ + +
+ +
+ +
+

Capacitor Voltage vs Time

+ +
+ +
+

Current vs Time

+ +
+
+
+
+
+
+ + + + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/inclined.html b/web/virtual_lab/templates/virtual_lab/physics/inclined.html new file mode 100644 index 0000000..719b840 --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/physics/inclined.html @@ -0,0 +1,179 @@ +{# web/virtual_lab/templates/virtual_lab/physics/inclined.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} + +{% block virtual_lab_content %} +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Inclined Plane Dynamics

+

+ Drag the block to any starting point, adjust angle, friction, and mass. + Click “Launch” to let it slide, watch live readouts, energy bars, force vectors, and a real-time graph. + Once it reaches the bottom, a short quiz will appear. +

+
+
+
+
+
+ + + 30° +
+
+ + + 0.00 +
+
+ + + 1.0 kg +
+
+ + + +
+
+ +
+ + +
+

+ Distance ↓: 0.00 m +

+

+ Speed: 0.00 m/s +

+

+ Accel: 0.00 m/s² +

+

+ PE: 0.00 J +

+

+ KE: 0.00 J +

+
+
+ +
+
+
+ mg sin α +
+
+
+ Normal (mg cos α) +
+
+
+ Friction (μ mg cos α) +
+
+
+ +
+ +
+

Position vs Time

+ +
+ +
+
+

Potential Energy

+
+ +
+
+
+

Kinetic Energy

+
+ +
+
+
+ + +
+
+
+
+
+ + + + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/mass_spring.html b/web/virtual_lab/templates/virtual_lab/physics/mass_spring.html new file mode 100644 index 0000000..b541b16 --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/physics/mass_spring.html @@ -0,0 +1,178 @@ +{# web/virtual_lab/templates/virtual_lab/physics/mass_spring.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} + +{% block virtual_lab_content %} +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Mass–Spring Oscillation

+

+ Drag the mass horizontally to set its initial displacement. Adjust the spring constant \(k\) and mass \(m\). + Click “Start” to see the mass oscillate. A live Position vs. Time graph and numeric readouts will update in real time. + After one full oscillation, a post-lab quiz will appear. +

+
+
+ +
+ +
+
+ + + 25 +
+
+ + + 1.0 kg +
+
+ + + 0.20 m +
+
+ + + +
+
+ +
+ + +
+

+ Time: 0.00 s +

+

+ Position: 0.00 m +

+

+ Velocity: 0.00 m/s +

+

+ Acceleration: 0.00 m/s² +

+

+ Potential (½kx²): 0.00 J +

+

+ Kinetic (½mv²): 0.00 J +

+
+
+ + +
+ +
+ +
+

Position vs Time

+ +
+ +
+
+

Potential Energy

+
+ +
+
+
+

Kinetic Energy

+
+ +
+
+
+
+
+
+
+
+ + + + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/pendulum.html b/web/virtual_lab/templates/virtual_lab/physics/pendulum.html new file mode 100644 index 0000000..e9a4f6a --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/physics/pendulum.html @@ -0,0 +1,127 @@ +{# web/virtual_lab/templates/virtual_lab/physics/pendulum.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} + +{% block virtual_lab_content %} +
+ {# ---------------- Tutorial Overlay (unchanged) ---------------- #} +
+
+

+ Step 1 of 5 +

+
    + {# Animated bullet points inserted by JS #} +
+
+ + + +
+
+
+ {# -------------- End Tutorial Overlay -------------- #} +
+
+ +
+

Pendulum Motion

+

+ Follow the animated tutorial above, then start the simulation. Watch the pendulum swing and see its trail. Numerical readouts appear at top‐left. +

+
+ {# -------- Pendulum area with trail, mini-graph, and readouts -------- #} +
+ + + {# Numerical Readouts in top-left corner #} +
+
+ t = 0.00 s +
+
+ θ = 0.0° +
+
+ v = 0.0 m/s +
+
+ {# Small graph overlaid in top-right #} +
+

θ vs t

+ + +
+
+ {# -------- End Pendulum area -------- #} + {# -------- Energy Bars -------- #} +
+
+
Potential Energy
+
+
+
+
+
+
Kinetic Energy
+
+
+
+
+
+ {# -------- End Energy Bars -------- #} + +
+
+ + + 1.0 m +
+ + +
+ + +
+
+
+ {# Include the updated JS with audio references removed #} + +{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/projectile.html b/web/virtual_lab/templates/virtual_lab/physics/projectile.html new file mode 100644 index 0000000..af9e51d --- /dev/null +++ b/web/virtual_lab/templates/virtual_lab/physics/projectile.html @@ -0,0 +1,158 @@ +{# web/virtual_lab/templates/virtual_lab/physics/projectile.html #} +{% extends "virtual_lab/layout.html" %} + +{% load static %} + +{% block virtual_lab_content %} +
+ {# ---------------- Pre-Lab Tutorial Overlay ---------------- #} +
+
+

+ Step 1 of 5 +

+
    + {# JS will inject bullet points here with fade-in #} +
+
+ + + +
+
+
+ {# -------------- End Tutorial Overlay -------------- #} +
+
+ +
+

Projectile Motion

+

+ Click‐and‐drag from the launch pad (white circle) at left to set speed and angle. +
+ Release to fire. Adjust gravity or wind on the fly. + Watch the path and vectors, and see “y vs x” plotted live. +

+
+ {# -------- Main Simulation Area: Canvas + Plot -------- #} +
+ {# ---------------- Projectile Canvas ---------------- #} +
+ + + {# Launch Pad Icon (white circle)—drawn via JS, but reserve a tooltip #} +
+ + Launch Pad +
+ {# Numeric Readouts (top-left) #} +
+
+ t = 0.00 s +
+
+ x = 0.00 m +
+
+ y = 0.00 m +
+
+ vₓ = 0.00 m/s +
+
+ v_y = 0.00 m/s +
+
+ {# Target Indicator (blue vertical marker), drawn by JS #} +
+ {# ---------------- Live Plot: y vs x ---------------- #} +
+

Trajectory: y vs x

+ + +
+
+ {# -------- End Simulation + Plot -------- #} + {# -------- Mid-Flight “What-If” Controls -------- #} +
+
Mid-Flight Controls:
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ {# -------- End Mid-Flight Controls -------- #} + {# -------- Post-Lab Quiz / Target Info (hidden initially) -------- #} + + {# -------- End Quiz -------- #} +
+
+
+ {# Include the JavaScript for all new features #} + +{% endblock %} diff --git a/web/virtual_lab/tests.py b/web/virtual_lab/tests.py new file mode 100644 index 0000000..e69de29 diff --git a/web/virtual_lab/urls.py b/web/virtual_lab/urls.py new file mode 100644 index 0000000..bd8b671 --- /dev/null +++ b/web/virtual_lab/urls.py @@ -0,0 +1,37 @@ +from django.urls import path + +from .views import ( + chemistry_home, + code_editor_view, + evaluate_code, + ph_indicator_view, + physics_electrical_circuit_view, + physics_inclined_view, + physics_mass_spring_view, + physics_pendulum_view, + physics_projectile_view, + precipitation_view, + reaction_rate_view, + solubility_view, + titration_view, + virtual_lab_home, +) + +app_name = "virtual_lab" + +urlpatterns = [ + path("", virtual_lab_home, name="virtual_lab_home"), + path("physics/pendulum/", physics_pendulum_view, name="physics_pendulum"), + path("physics/projectile/", physics_projectile_view, name="physics_projectile"), + path("physics/inclined/", physics_inclined_view, name="physics_inclined"), + path("physics/mass_spring/", physics_mass_spring_view, name="physics_mass_spring"), + path("physics/circuit/", physics_electrical_circuit_view, name="physics_electrical_circuit"), + path("virtual_lab/chemistry/", chemistry_home, name="chemistry_home"), + path("virtual_lab/chemistry/titration/", titration_view, name="titration"), + path("virtual_lab/chemistry/reaction-rate/", reaction_rate_view, name="reaction_rate"), + path("virtual_lab/chemistry/solubility/", solubility_view, name="solubility"), + path("virtual_lab/chemistry/precipitation/", precipitation_view, name="precipitation"), + path("virtual_lab/chemistry/ph-indicator/", ph_indicator_view, name="ph_indicator"), + path("code-editor/", code_editor_view, name="code_editor"), + path("evaluate-code/", evaluate_code, name="evaluate_code"), +] diff --git a/web/virtual_lab/views.py b/web/virtual_lab/views.py new file mode 100644 index 0000000..cee652a --- /dev/null +++ b/web/virtual_lab/views.py @@ -0,0 +1,136 @@ +# web/virtual_lab/views.py + +import json +import logging + +import requests +from django.http import JsonResponse +from django.shortcuts import render +from django.views.decorators.http import require_POST + +logger = logging.getLogger(__name__) + + +def virtual_lab_home(request): + """ + Renders the Virtual Lab home page (home.html). + """ + return render(request, "virtual_lab/home.html") + + +def physics_pendulum_view(request): + """ + Renders the Pendulum Motion simulation page (physics/pendulum.html). + """ + return render(request, "virtual_lab/physics/pendulum.html") + + +def physics_projectile_view(request): + """ + Renders the Projectile Motion simulation page (physics/projectile.html). + """ + return render(request, "virtual_lab/physics/projectile.html") + + +def physics_inclined_view(request): + """ + Renders the Inclined Plane simulation page (physics/inclined.html). + """ + return render(request, "virtual_lab/physics/inclined.html") + + +def physics_mass_spring_view(request): + """ + Renders the Mass-Spring Oscillation simulation page (physics/mass_spring.html). + """ + return render(request, "virtual_lab/physics/mass_spring.html") + + +def physics_electrical_circuit_view(request): + """ + Renders the Electrical Circuit simulation page (physics/circuit.html). + """ + return render(request, "virtual_lab/physics/circuit.html") + + +def chemistry_home(request): + return render(request, "virtual_lab/chemistry/index.html") + + +def titration_view(request): + return render(request, "virtual_lab/chemistry/titration.html") + + +def reaction_rate_view(request): + return render(request, "virtual_lab/chemistry/reaction_rate.html") + + +def solubility_view(request): + return render(request, "virtual_lab/chemistry/solubility.html") + + +def precipitation_view(request): + return render(request, "virtual_lab/chemistry/precipitation.html") + + +def ph_indicator_view(request): + return render(request, "virtual_lab/chemistry/ph_indicator.html") + + +# Piston’s public execute endpoint (rate-limited to 5 req/s) :contentReference[oaicite:0]{index=0} +PISTON_EXECUTE_URL = "https://emkc.org/api/v2/piston/execute" + +LANG_FILE_EXT = { + "python": "py", + "javascript": "js", + "c": "c", + "cpp": "cpp", +} + + +def code_editor_view(request): + return render(request, "virtual_lab/code_editor/code_editor.html") + + +@require_POST +def evaluate_code(request): + """ + Proxy code + stdin to Piston and return its JSON result. + """ + data = json.loads(request.body) + source_code = data.get("code", "") + language = data.get("language", "python") # e.g. "python","javascript","c","cpp" + stdin_text = data.get("stdin", "") + + # Package content for Piston + ext = LANG_FILE_EXT.get(language, "txt") + files = [{"name": f"main.{ext}", "content": source_code}] + payload = { + "language": language, + "version": "*", # semver selector; '*' picks latest :contentReference[oaicite:1]{index=1} + "files": files, + "stdin": stdin_text, + "args": [], + } + + try: + resp = requests.post(PISTON_EXECUTE_URL, json=payload, timeout=10) + resp.raise_for_status() + except requests.RequestException: + # Log the full details for your own troubleshooting + logger.exception("Failed to call Piston execute endpoint") + # Return a safe, generic message to the user + return JsonResponse( + {"stderr": "Code execution service is currently unavailable. Please try again later."}, status=502 + ) + + result = resp.json() + # Piston returns a structure like: + # { language, version, run: { stdout, stderr, code, signal, output } } + run = result.get("run", {}) + return JsonResponse( + { + "stdout": run.get("stdout", run.get("output", "")), + "stderr": run.get("stderr", ""), + } + ) diff --git a/web/wsgi.py b/web/wsgi.py new file mode 100644 index 0000000..edfa214 --- /dev/null +++ b/web/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'web.settings') +application = get_wsgi_application() From 1cf081255bd2b88b5499403dbd061be022f1fcb6 Mon Sep 17 00:00:00 2001 From: Lakshya-2440 Date: Tue, 24 Feb 2026 02:35:21 +0530 Subject: [PATCH 2/2] Convert Django backend to plain static HTML/CSS/JS site --- fix_urls.py | 22 - web/templates/index.html => index.html | 8 +- manage.py | 22 - poetry.lock | 1935 ---- pyproject.toml | 66 - remove_navbar.py | 19 - image.png => static/img/image.png | Bin .../virtual_lab/js/chemistry/ph_indicator.js | 0 .../virtual_lab/js/chemistry/precipitation.js | 0 .../virtual_lab/js/chemistry/reaction_rate.js | 0 .../virtual_lab/js/chemistry/solubility.js | 0 .../virtual_lab/js/chemistry/titration.js | 0 static/virtual_lab/js/code_editor.js | 68 + .../virtual_lab/js/common.js | 0 .../js/physics_electrical_circuit.js | 0 .../virtual_lab/js/physics_inclined.js | 0 .../virtual_lab/js/physics_mass_spring.js | 0 .../virtual_lab/js/physics_pendulum.js | 0 .../virtual_lab/js/physics_projectile.js | 0 virtual_lab/chemistry/index.html | 663 ++ virtual_lab/chemistry/ph-indicator/index.html | 661 ++ .../chemistry/precipitation/index.html | 659 ++ .../chemistry/reaction-rate/index.html | 656 ++ .../chemistry/solubility/index.html | 167 +- virtual_lab/chemistry/titration/index.html | 680 ++ virtual_lab/code-editor/index.html | 684 ++ virtual_lab/index.html | 657 ++ virtual_lab/physics/circuit/index.html | 763 ++ virtual_lab/physics/inclined/index.html | 785 ++ virtual_lab/physics/mass_spring/index.html | 784 ++ virtual_lab/physics/pendulum/index.html | 733 ++ virtual_lab/physics/projectile/index.html | 764 ++ web/__init__.py | 0 web/settings.py | 505 - web/static/img/image.png | Bin 5111 -> 0 bytes web/urls.py | 16 - web/views.py | 8846 ----------------- web/virtual_lab/__init__.py | 0 web/virtual_lab/apps.py | 10 - web/virtual_lab/css/virtual_lab.css | 0 web/virtual_lab/models.py | 0 .../static/virtual_lab/js/code_editor.js | 53 - .../virtual_lab/chemistry/index.html | 58 - .../virtual_lab/chemistry/ph_indicator.html | 58 - .../virtual_lab/chemistry/precipitation.html | 56 - .../virtual_lab/chemistry/reaction_rate.html | 54 - .../virtual_lab/chemistry/solubility.html | 53 - .../virtual_lab/chemistry/titration.html | 78 - .../virtual_lab/code_editor/code_editor.html | 82 - .../templates/virtual_lab/home.html | 51 - .../templates/virtual_lab/layout.html | 77 - .../virtual_lab/physics/circuit.html | 157 - .../virtual_lab/physics/inclined.html | 179 - .../virtual_lab/physics/mass_spring.html | 178 - .../virtual_lab/physics/pendulum.html | 127 - .../virtual_lab/physics/projectile.html | 158 - web/virtual_lab/tests.py | 0 web/virtual_lab/urls.py | 37 - web/virtual_lab/views.py | 136 - web/wsgi.py | 5 - 60 files changed, 8687 insertions(+), 13083 deletions(-) delete mode 100644 fix_urls.py rename web/templates/index.html => index.html (99%) delete mode 100644 manage.py delete mode 100644 poetry.lock delete mode 100644 pyproject.toml delete mode 100644 remove_navbar.py rename image.png => static/img/image.png (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/chemistry/ph_indicator.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/chemistry/precipitation.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/chemistry/reaction_rate.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/chemistry/solubility.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/chemistry/titration.js (100%) create mode 100644 static/virtual_lab/js/code_editor.js rename {web/virtual_lab/static => static}/virtual_lab/js/common.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/physics_electrical_circuit.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/physics_inclined.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/physics_mass_spring.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/physics_pendulum.js (100%) rename {web/virtual_lab/static => static}/virtual_lab/js/physics_projectile.js (100%) create mode 100644 virtual_lab/chemistry/index.html create mode 100644 virtual_lab/chemistry/ph-indicator/index.html create mode 100644 virtual_lab/chemistry/precipitation/index.html create mode 100644 virtual_lab/chemistry/reaction-rate/index.html rename web/templates/base.html => virtual_lab/chemistry/solubility/index.html (71%) create mode 100644 virtual_lab/chemistry/titration/index.html create mode 100644 virtual_lab/code-editor/index.html create mode 100644 virtual_lab/index.html create mode 100644 virtual_lab/physics/circuit/index.html create mode 100644 virtual_lab/physics/inclined/index.html create mode 100644 virtual_lab/physics/mass_spring/index.html create mode 100644 virtual_lab/physics/pendulum/index.html create mode 100644 virtual_lab/physics/projectile/index.html delete mode 100644 web/__init__.py delete mode 100644 web/settings.py delete mode 100644 web/static/img/image.png delete mode 100644 web/urls.py delete mode 100644 web/views.py delete mode 100644 web/virtual_lab/__init__.py delete mode 100644 web/virtual_lab/apps.py delete mode 100644 web/virtual_lab/css/virtual_lab.css delete mode 100644 web/virtual_lab/models.py delete mode 100644 web/virtual_lab/static/virtual_lab/js/code_editor.js delete mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/index.html delete mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/ph_indicator.html delete mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/precipitation.html delete mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/reaction_rate.html delete mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/solubility.html delete mode 100644 web/virtual_lab/templates/virtual_lab/chemistry/titration.html delete mode 100644 web/virtual_lab/templates/virtual_lab/code_editor/code_editor.html delete mode 100644 web/virtual_lab/templates/virtual_lab/home.html delete mode 100644 web/virtual_lab/templates/virtual_lab/layout.html delete mode 100644 web/virtual_lab/templates/virtual_lab/physics/circuit.html delete mode 100644 web/virtual_lab/templates/virtual_lab/physics/inclined.html delete mode 100644 web/virtual_lab/templates/virtual_lab/physics/mass_spring.html delete mode 100644 web/virtual_lab/templates/virtual_lab/physics/pendulum.html delete mode 100644 web/virtual_lab/templates/virtual_lab/physics/projectile.html delete mode 100644 web/virtual_lab/tests.py delete mode 100644 web/virtual_lab/urls.py delete mode 100644 web/virtual_lab/views.py delete mode 100644 web/wsgi.py diff --git a/fix_urls.py b/fix_urls.py deleted file mode 100644 index 1b71997..0000000 --- a/fix_urls.py +++ /dev/null @@ -1,22 +0,0 @@ -import re -import os - -filepath = "web/templates/base.html" -with open(filepath, "r") as f: - content = f.read() - -# Find all {% url 'something' ... %} -# We'll use a regex that matches the whole tag -pattern = r"{%\s*url\s+['\"]([^'\"]+)['\"][^%]*%}" - -def replace_url(match): - url_target = match.group(1) - if url_target.startswith("virtual_lab"): - return match.group(0) # Keep virtual lab urls - return "#" - -new_content = re.sub(pattern, replace_url, content) - -with open(filepath, "w") as f: - f.write(new_content) -print("Replaced broken urls in base.html") diff --git a/web/templates/index.html b/index.html similarity index 99% rename from web/templates/index.html rename to index.html index e7ddd89..9e3a43f 100644 --- a/web/templates/index.html +++ b/index.html @@ -1,4 +1,4 @@ -{% verbatim %} + @@ -553,7 +553,7 @@

{ + { e.preventDefault(); const overlay = document.getElementById('page-transition'); overlay.classList.add('active'); @@ -772,9 +772,9 @@

-{% endverbatim %} + diff --git a/manage.py b/manage.py deleted file mode 100644 index 116ccc9..0000000 --- a/manage.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python3.10 -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web.settings") - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index c0516b7..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1935 +0,0 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "asgiref" -version = "3.9.1" -description = "ASGI specs, helper code, and adapters" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c"}, - {file = "asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142"}, -] - -[package.dependencies] -typing_extensions = {version = ">=4", markers = "python_version < \"3.11\""} - -[package.extras] -tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"] - -[[package]] -name = "async-timeout" -version = "5.0.1" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_full_version < \"3.11.3\"" -files = [ - {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, - {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, -] - -[[package]] -name = "bleach" -version = "6.2.0" -description = "An easy safelist-based HTML-sanitizing tool." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e"}, - {file = "bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f"}, -] - -[package.dependencies] -webencodings = "*" - -[package.extras] -css = ["tinycss2 (>=1.1.0,<1.5)"] - -[[package]] -name = "cachetools" -version = "5.5.1" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb"}, - {file = "cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95"}, -] - -[[package]] -name = "certifi" -version = "2025.1.31" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, -] - -[[package]] -name = "cffi" -version = "1.17.1" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, - {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, - {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, - {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, - {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, - {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, - {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, - {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, - {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, - {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, - {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, - {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, - {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, - {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, - {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, - {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, - {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, - {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, - {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, - {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, - {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, - {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, - {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, - {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, - {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, - {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, - {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, - {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, - {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, - {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, - {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, -] - -[package.dependencies] -pycparser = "*" - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "channels" -version = "4.3.1" -description = "Brings async, event-driven capabilities to Django." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "channels-4.3.1-py3-none-any.whl", hash = "sha256:b091d4b26f91d807de3e84aead7ba785314f27eaf5bac31dd51b1c956b883859"}, - {file = "channels-4.3.1.tar.gz", hash = "sha256:97413ffd674542db08e16a9ef09cd86ec0113e5f8125fbd33cf0854adcf27cdb"}, -] - -[package.dependencies] -asgiref = ">=3.9.0,<4" -Django = ">=4.2" - -[package.extras] -daphne = ["daphne (>=4.0.0)"] -tests = ["async-timeout", "coverage (>=4.5,<5.0)", "pytest", "pytest-asyncio", "pytest-django", "selenium"] - -[[package]] -name = "channels-redis" -version = "4.3.0" -description = "Redis-backed ASGI channel layer implementation" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5"}, - {file = "channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2"}, -] - -[package.dependencies] -asgiref = ">=3.9.1,<4" -channels = ">=4.2.2" -msgpack = ">=1.0,<2.0" -redis = ">=4.6" - -[package.extras] -cryptography = ["cryptography (>=1.3.0)"] -tests = ["async-timeout", "cryptography (>=1.3.0)", "pytest", "pytest-asyncio", "pytest-timeout"] - -[[package]] -name = "charset-normalizer" -version = "3.4.1" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, -] - -[[package]] -name = "click" -version = "8.1.8" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "cryptography" -version = "44.0.2" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = "!=3.9.0,!=3.9.1,>=3.7" -groups = ["main"] -files = [ - {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"}, - {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"}, - {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"}, - {file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"}, - {file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"}, - {file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"}, - {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"}, - {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"}, - {file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"}, - {file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"}, - {file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"}, - {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, - {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, -] - -[package.dependencies] -cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] -pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "cssbeautifier" -version = "1.15.3" -description = "CSS unobfuscator and beautifier." -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "cssbeautifier-1.15.3-py3-none-any.whl", hash = "sha256:0dcaf5ce197743a79b3a160b84ea58fcbd9e3e767c96df1171e428125b16d410"}, - {file = "cssbeautifier-1.15.3.tar.gz", hash = "sha256:406b04d09e7d62c0be084fbfa2cba5126fe37359ea0d8d9f7b963a6354fc8303"}, -] - -[package.dependencies] -editorconfig = ">=0.12.2" -jsbeautifier = "*" -six = ">=1.13.0" - -[[package]] -name = "distlib" -version = "0.3.9" -description = "Distribution utilities" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, - {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, -] - -[[package]] -name = "django" -version = "5.1.15" -description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432"}, - {file = "django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947"}, -] - -[package.dependencies] -asgiref = ">=3.8.1,<4" -sqlparse = ">=0.3.1" -tzdata = {version = "*", markers = "sys_platform == \"win32\""} - -[package.extras] -argon2 = ["argon2-cffi (>=19.1.0)"] -bcrypt = ["bcrypt"] - -[[package]] -name = "django-allauth" -version = "65.4.1" -description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "django_allauth-65.4.1.tar.gz", hash = "sha256:60b32aef7dbbcc213319aa4fd8f570e985266ea1162ae6ef7a26a24efca85c8c"}, -] - -[package.dependencies] -asgiref = ">=3.8.1" -Django = ">=4.2.16" - -[package.extras] -mfa = ["fido2 (>=1.1.2)", "qrcode (>=7.0.0)"] -openid = ["python3-openid (>=3.0.8)"] -saml = ["python3-saml (>=1.15.0,<2.0.0)"] -socialaccount = ["pyjwt[crypto] (>=1.7)", "requests (>=2.0.0)", "requests-oauthlib (>=0.3.0)"] -steam = ["python3-openid (>=3.0.8)"] - -[[package]] -name = "django-browser-reload" -version = "1.18.0" -description = "Automatically reload your browser in development." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "django_browser_reload-1.18.0-py3-none-any.whl", hash = "sha256:ed4cc2fb83c3bf6c30b54107a1a6736c0b896e62e4eba666d81005b9f2ecf6f8"}, - {file = "django_browser_reload-1.18.0.tar.gz", hash = "sha256:c5f0b134723cbf2a0dc9ae1ee1d38e42db28fe23c74cdee613ba3ef286d04735"}, -] - -[package.dependencies] -asgiref = ">=3.6" -django = ">=4.2" - -[[package]] -name = "django-environ" -version = "0.11.2" -description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application." -optional = false -python-versions = ">=3.6,<4" -groups = ["main"] -files = [ - {file = "django-environ-0.11.2.tar.gz", hash = "sha256:f32a87aa0899894c27d4e1776fa6b477e8164ed7f6b3e410a62a6d72caaf64be"}, - {file = "django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05"}, -] - -[package.extras] -develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.dev0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] -docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] -testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"] - -[[package]] -name = "django-markdownx" -version = "4.0.7" -description = "A comprehensive Markdown editor built for Django." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "django-markdownx-4.0.7.tar.gz", hash = "sha256:38aa331c2ca0bee218b77f462361b5393e4727962bc6021939c09048363cb6ea"}, - {file = "django_markdownx-4.0.7-py2.py3-none-any.whl", hash = "sha256:c1975ae3053481d4c111abd38997a5b5bb89235a1e3215f995d835942925fe7b"}, -] - -[package.dependencies] -Django = "*" -Markdown = "*" -Pillow = "*" - -[[package]] -name = "django-ranged-response" -version = "0.2.0" -description = "Modified Django FileResponse that adds Content-Range headers." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "django-ranged-response-0.2.0.tar.gz", hash = "sha256:f71fff352a37316b9bead717fc76e4ddd6c9b99c4680cdf4783b9755af1cf985"}, -] - -[package.dependencies] -django = "*" - -[[package]] -name = "django-simple-captcha" -version = "0.5.20" -description = "A very simple, yet powerful, Django captcha application" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "django-simple-captcha-0.5.20.tar.gz", hash = "sha256:20273009a7beb44297e9f6c7a6bd21ada3d2fa93c314d2f6bf5e394ceeb6a297"}, - {file = "django_simple_captcha-0.5.20-py2.py3-none-any.whl", hash = "sha256:3359cb033c489eae6544a80ad92517db3d35b3b328b3b427393399c3d7f55275"}, -] - -[package.dependencies] -Django = ">=3.2" -django-ranged-response = "0.2.0" -Pillow = ">=6.2.0" - -[package.extras] -test = ["testfixtures"] - -[[package]] -name = "django-storages" -version = "1.14.4" -description = "Support for many storage backends in Django" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "django-storages-1.14.4.tar.gz", hash = "sha256:69aca94d26e6714d14ad63f33d13619e697508ee33ede184e462ed766dc2a73f"}, - {file = "django_storages-1.14.4-py3-none-any.whl", hash = "sha256:d61930acb4a25e3aebebc6addaf946a3b1df31c803a6bf1af2f31c9047febaa3"}, -] - -[package.dependencies] -Django = ">=3.2" - -[package.extras] -azure = ["azure-core (>=1.13)", "azure-storage-blob (>=12)"] -boto3 = ["boto3 (>=1.4.4)"] -dropbox = ["dropbox (>=7.2.1)"] -google = ["google-cloud-storage (>=1.27)"] -libcloud = ["apache-libcloud"] -s3 = ["boto3 (>=1.4.4)"] -sftp = ["paramiko (>=1.15)"] - -[[package]] -name = "djlint" -version = "1.36.4" -description = "HTML Template Linter and Formatter" -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "djlint-1.36.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a2dfb60883ceb92465201bfd392291a7597c6752baede6fbb6f1980cac8d6c5c"}, - {file = "djlint-1.36.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4bc6a1320c0030244b530ac200642f883d3daa451a115920ef3d56d08b644292"}, - {file = "djlint-1.36.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3164a048c7bb0baf042387b1e33f9bbbf99d90d1337bb4c3d66eb0f96f5400a1"}, - {file = "djlint-1.36.4-cp310-cp310-win_amd64.whl", hash = "sha256:3196d5277da5934962d67ad6c33a948ba77a7b6eadf064648bef6ee5f216b03c"}, - {file = "djlint-1.36.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d68da0ed10ee9ca1e32e225cbb8e9b98bf7e6f8b48a8e4836117b6605b88cc7"}, - {file = "djlint-1.36.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c0478d5392247f1e6ee29220bbdbf7fb4e1bc0e7e83d291fda6fb926c1787ba7"}, - {file = "djlint-1.36.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:962f7b83aee166e499eff916d631c6dde7f1447d7610785a60ed2a75a5763483"}, - {file = "djlint-1.36.4-cp311-cp311-win_amd64.whl", hash = "sha256:53cbc450aa425c832f09bc453b8a94a039d147b096740df54a3547fada77ed08"}, - {file = "djlint-1.36.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff9faffd7d43ac20467493fa71d5355b5b330a00ade1c4d1e859022f4195223b"}, - {file = "djlint-1.36.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:79489e262b5ac23a8dfb7ca37f1eea979674cfc2d2644f7061d95bea12c38f7e"}, - {file = "djlint-1.36.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e58c5fa8c6477144a0be0a87273706a059e6dd0d6efae01146ae8c29cdfca675"}, - {file = "djlint-1.36.4-cp312-cp312-win_amd64.whl", hash = "sha256:bb6903777bf3124f5efedcddf1f4716aef097a7ec4223fc0fa54b865829a6e08"}, - {file = "djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2"}, - {file = "djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835"}, - {file = "djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f"}, - {file = "djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4"}, - {file = "djlint-1.36.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:89678661888c03d7bc6cadd75af69db29962b5ecbf93a81518262f5c48329f04"}, - {file = "djlint-1.36.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b01a98df3e1ab89a552793590875bc6e954cad661a9304057db75363d519fa0"}, - {file = "djlint-1.36.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dabbb4f7b93223d471d09ae34ed515fef98b2233cbca2449ad117416c44b1351"}, - {file = "djlint-1.36.4-cp39-cp39-win_amd64.whl", hash = "sha256:7a483390d17e44df5bc23dcea29bdf6b63f3ed8b4731d844773a4829af4f5e0b"}, - {file = "djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd"}, - {file = "djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1"}, -] - -[package.dependencies] -click = ">=8.0.1" -colorama = ">=0.4.4" -cssbeautifier = ">=1.14.4" -jsbeautifier = ">=1.14.4" -json5 = ">=0.9.11" -pathspec = ">=0.12" -pyyaml = ">=6" -regex = ">=2023" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -tqdm = ">=4.62.2" -typing-extensions = {version = ">=3.6.6", markers = "python_version < \"3.11\""} - -[[package]] -name = "editorconfig" -version = "0.17.0" -description = "EditorConfig File Locator and Interpreter for Python" -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "EditorConfig-0.17.0-py3-none-any.whl", hash = "sha256:fe491719c5f65959ec00b167d07740e7ffec9a3f362038c72b289330b9991dfc"}, - {file = "editorconfig-0.17.0.tar.gz", hash = "sha256:8739052279699840065d3a9f5c125d7d5a98daeefe53b0e5274261d77cb49aa2"}, -] - -[[package]] -name = "filelock" -version = "3.17.0" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338"}, - {file = "filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] -typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] - -[[package]] -name = "google-api-core" -version = "2.24.1" -description = "Google API client core library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_api_core-2.24.1-py3-none-any.whl", hash = "sha256:bc78d608f5a5bf853b80bd70a795f703294de656c096c0968320830a4bc280f1"}, - {file = "google_api_core-2.24.1.tar.gz", hash = "sha256:f8b36f5456ab0dd99a1b693a40a31d1e7757beea380ad1b38faaf8941eae9d8a"}, -] - -[package.dependencies] -google-auth = ">=2.14.1,<3.0.dev0" -googleapis-common-protos = ">=1.56.2,<2.0.dev0" -proto-plus = [ - {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, - {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, -] -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" -requests = ">=2.18.0,<3.0.0.dev0" - -[package.extras] -async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"] -grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0) ; python_version >= \"3.11\""] -grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] -grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] - -[[package]] -name = "google-api-python-client" -version = "2.161.0" -description = "Google API Client Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_api_python_client-2.161.0-py2.py3-none-any.whl", hash = "sha256:9476a5a4f200bae368140453df40f9cda36be53fa7d0e9a9aac4cdb859a26448"}, - {file = "google_api_python_client-2.161.0.tar.gz", hash = "sha256:324c0cce73e9ea0a0d2afd5937e01b7c2d6a4d7e2579cdb6c384f9699d6c9f37"}, -] - -[package.dependencies] -google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" -google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" -google-auth-httplib2 = ">=0.2.0,<1.0.0" -httplib2 = ">=0.19.0,<1.dev0" -uritemplate = ">=3.0.1,<5" - -[[package]] -name = "google-auth" -version = "2.38.0" -description = "Google Authentication Library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a"}, - {file = "google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4"}, -] - -[package.dependencies] -cachetools = ">=2.0.0,<6.0" -pyasn1-modules = ">=0.2.1" -rsa = ">=3.1.4,<5" - -[package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] -enterprise-cert = ["cryptography", "pyopenssl"] -pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"] -pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] -reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0.dev0)"] - -[[package]] -name = "google-auth-httplib2" -version = "0.2.0" -description = "Google Authentication Library: httplib2 transport" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, - {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, -] - -[package.dependencies] -google-auth = "*" -httplib2 = ">=0.19.0" - -[[package]] -name = "google-auth-oauthlib" -version = "1.2.1" -description = "Google Authentication Library" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "google_auth_oauthlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:2d58a27262d55aa1b87678c3ba7142a080098cbc2024f903c62355deb235d91f"}, - {file = "google_auth_oauthlib-1.2.1.tar.gz", hash = "sha256:afd0cad092a2eaa53cd8e8298557d6de1034c6cb4a740500b5357b648af97263"}, -] - -[package.dependencies] -google-auth = ">=2.15.0" -requests-oauthlib = ">=0.7.0" - -[package.extras] -tool = ["click (>=6.0.0)"] - -[[package]] -name = "googleapis-common-protos" -version = "1.67.0" -description = "Common protobufs used in Google APIs" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "googleapis_common_protos-1.67.0-py2.py3-none-any.whl", hash = "sha256:579de760800d13616f51cf8be00c876f00a9f146d3e6510e19d1f4111758b741"}, - {file = "googleapis_common_protos-1.67.0.tar.gz", hash = "sha256:21398025365f138be356d5923e9168737d94d46a72aefee4a6110a1f23463c86"}, -] - -[package.dependencies] -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" - -[package.extras] -grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "httplib2" -version = "0.22.0" -description = "A comprehensive HTTP client library." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["main"] -files = [ - {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, - {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, -] - -[package.dependencies] -pyparsing = {version = ">=2.4.2,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.0.2 || >3.0.2,<3.0.3 || >3.0.3,<4", markers = "python_version > \"3.0\""} - -[[package]] -name = "icalendar" -version = "5.0.13" -description = "iCalendar parser/generator" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "icalendar-5.0.13-py3-none-any.whl", hash = "sha256:5ded5415e2e1edef5ab230024a75878a7a81d518a3b1ae4f34bf20b173c84dc2"}, - {file = "icalendar-5.0.13.tar.gz", hash = "sha256:92799fde8cce0b61daa8383593836d1e19136e504fa1671f471f98be9b029706"}, -] - -[package.dependencies] -python-dateutil = "*" -pytz = "*" - -[[package]] -name = "identify" -version = "2.6.7" -description = "File identification library for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0"}, - {file = "identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "jsbeautifier" -version = "1.15.3" -description = "JavaScript unobfuscator and beautifier." -optional = false -python-versions = "*" -groups = ["main", "dev"] -files = [ - {file = "jsbeautifier-1.15.3-py3-none-any.whl", hash = "sha256:b207a15ab7529eee4a35ae7790e9ec4e32a2b5026d51e2d0386c3a65e6ecfc91"}, - {file = "jsbeautifier-1.15.3.tar.gz", hash = "sha256:5f1baf3d4ca6a615bb5417ee861b34b77609eeb12875555f8bbfabd9bf2f3457"}, -] - -[package.dependencies] -editorconfig = ">=0.12.2" -six = ">=1.13.0" - -[[package]] -name = "json5" -version = "0.10.0" -description = "A Python implementation of the JSON5 data format." -optional = false -python-versions = ">=3.8.0" -groups = ["main", "dev"] -files = [ - {file = "json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa"}, - {file = "json5-0.10.0.tar.gz", hash = "sha256:e66941c8f0a02026943c52c2eb34ebeb2a6f819a0be05920a6f5243cd30fd559"}, -] - -[package.extras] -dev = ["build (==1.2.2.post1)", "coverage (==7.5.3)", "mypy (==1.13.0)", "pip (==24.3.1)", "pylint (==3.2.3)", "ruff (==0.7.3)", "twine (==5.1.1)", "uv (==0.5.1)"] - -[[package]] -name = "markdown" -version = "3.7" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, - {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, -] - -[package.extras] -docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "msgpack" -version = "1.1.1" -description = "MessagePack serializer" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed"}, - {file = "msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4"}, - {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75"}, - {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338"}, - {file = "msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd"}, - {file = "msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8"}, - {file = "msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558"}, - {file = "msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f"}, - {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2"}, - {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752"}, - {file = "msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295"}, - {file = "msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458"}, - {file = "msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238"}, - {file = "msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a"}, - {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef"}, - {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a"}, - {file = "msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c"}, - {file = "msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4"}, - {file = "msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0"}, - {file = "msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a"}, - {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7"}, - {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5"}, - {file = "msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323"}, - {file = "msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bba1be28247e68994355e028dcd668316db30c1f758d3241a7b903ac78dcd285"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f93dcddb243159c9e4109c9750ba5b335ab8d48d9522c5308cd05d7e3ce600"}, - {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fbbc0b906a24038c9958a1ba7ae0918ad35b06cb449d398b76a7d08470b0ed9"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:61e35a55a546a1690d9d09effaa436c25ae6130573b6ee9829c37ef0f18d5e78"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1abfc6e949b352dadf4bce0eb78023212ec5ac42f6abfd469ce91d783c149c2a"}, - {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:996f2609ddf0142daba4cefd767d6db26958aac8439ee41db9cc0db9f4c4c3a6"}, - {file = "msgpack-1.1.1-cp38-cp38-win32.whl", hash = "sha256:4d3237b224b930d58e9d83c81c0dba7aacc20fcc2f89c1e5423aa0529a4cd142"}, - {file = "msgpack-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:da8f41e602574ece93dbbda1fab24650d6bf2a24089f9e9dbb4f5730ec1e58ad"}, - {file = "msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b"}, - {file = "msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf"}, - {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88"}, - {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478"}, - {file = "msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57"}, - {file = "msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084"}, - {file = "msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd"}, -] - -[[package]] -name = "mysqlclient" -version = "2.2.7" -description = "Python interface to MySQL" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "mysqlclient-2.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:2e3c11f7625029d7276ca506f8960a7fd3c5a0a0122c9e7404e6a8fe961b3d22"}, - {file = "mysqlclient-2.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:a22d99d26baf4af68ebef430e3131bb5a9b722b79a9fcfac6d9bbf8a88800687"}, - {file = "mysqlclient-2.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:4b4c0200890837fc64014cc938ef2273252ab544c1b12a6c1d674c23943f3f2e"}, - {file = "mysqlclient-2.2.7-cp313-cp313-win_amd64.whl", hash = "sha256:201a6faa301011dd07bca6b651fe5aaa546d7c9a5426835a06c3172e1056a3c5"}, - {file = "mysqlclient-2.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:199dab53a224357dd0cb4d78ca0e54018f9cee9bf9ec68d72db50e0a23569076"}, - {file = "mysqlclient-2.2.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92af368ed9c9144737af569c86d3b6c74a012a6f6b792eb868384787b52bb585"}, - {file = "mysqlclient-2.2.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:977e35244fe6ef44124e9a1c2d1554728a7b76695598e4b92b37dc2130503069"}, - {file = "mysqlclient-2.2.7.tar.gz", hash = "sha256:24ae22b59416d5fcce7e99c9d37548350b4565baac82f95e149cac6ce4163845"}, -] - -[[package]] -name = "nodeenv" -version = "1.9.1" -description = "Node.js virtual environment builder" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -files = [ - {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, - {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, -] - -[[package]] -name = "oauth2client" -version = "4.1.3" -description = "OAuth 2.0 client library" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "oauth2client-4.1.3-py2.py3-none-any.whl", hash = "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac"}, - {file = "oauth2client-4.1.3.tar.gz", hash = "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6"}, -] - -[package.dependencies] -httplib2 = ">=0.9.1" -pyasn1 = ">=0.1.7" -pyasn1-modules = ">=0.0.5" -rsa = ">=3.1.4" -six = ">=1.6.1" - -[[package]] -name = "oauthlib" -version = "3.2.2" -description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, - {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, -] - -[package.extras] -rsa = ["cryptography (>=3.0.0)"] -signals = ["blinker (>=1.4.0)"] -signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "pillow" -version = "12.1.1" -description = "Python Imaging Library (fork)" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0"}, - {file = "pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4"}, - {file = "pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4"}, - {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e"}, - {file = "pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff"}, - {file = "pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40"}, - {file = "pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23"}, - {file = "pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9"}, - {file = "pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32"}, - {file = "pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af"}, - {file = "pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b"}, - {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5"}, - {file = "pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d"}, - {file = "pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c"}, - {file = "pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563"}, - {file = "pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80"}, - {file = "pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052"}, - {file = "pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397"}, - {file = "pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0"}, - {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3"}, - {file = "pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35"}, - {file = "pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a"}, - {file = "pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6"}, - {file = "pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9"}, - {file = "pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6"}, - {file = "pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60"}, - {file = "pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e"}, - {file = "pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717"}, - {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a"}, - {file = "pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029"}, - {file = "pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b"}, - {file = "pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1"}, - {file = "pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a"}, - {file = "pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da"}, - {file = "pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20"}, - {file = "pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13"}, - {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf"}, - {file = "pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524"}, - {file = "pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986"}, - {file = "pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c"}, - {file = "pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f"}, - {file = "pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642"}, - {file = "pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd"}, - {file = "pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f"}, - {file = "pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e"}, - {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0"}, - {file = "pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb"}, - {file = "pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f"}, - {file = "pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15"}, - {file = "pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f"}, - {file = "pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8"}, - {file = "pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f"}, - {file = "pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586"}, - {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce"}, - {file = "pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8"}, - {file = "pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36"}, - {file = "pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b"}, - {file = "pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735"}, - {file = "pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e"}, - {file = "pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4"}, -] - -[package.extras] -docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] -fpx = ["olefile"] -mic = ["olefile"] -test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] -xmp = ["defusedxml"] - -[[package]] -name = "platformdirs" -version = "4.3.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] - -[[package]] -name = "pre-commit" -version = "3.8.0" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, - {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "proto-plus" -version = "1.26.0" -description = "Beautiful, Pythonic protocol buffers" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "proto_plus-1.26.0-py3-none-any.whl", hash = "sha256:bf2dfaa3da281fc3187d12d224c707cb57214fb2c22ba854eb0c105a3fb2d4d7"}, - {file = "proto_plus-1.26.0.tar.gz", hash = "sha256:6e93d5f5ca267b54300880fff156b6a3386b3fa3f43b1da62e680fc0c586ef22"}, -] - -[package.dependencies] -protobuf = ">=3.19.0,<6.0.0dev" - -[package.extras] -testing = ["google-api-core (>=1.31.5)"] - -[[package]] -name = "protobuf" -version = "5.29.3" -description = "" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888"}, - {file = "protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a"}, - {file = "protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e"}, - {file = "protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84"}, - {file = "protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f"}, - {file = "protobuf-5.29.3-cp38-cp38-win32.whl", hash = "sha256:84a57163a0ccef3f96e4b6a20516cedcf5bb3a95a657131c5c3ac62200d23252"}, - {file = "protobuf-5.29.3-cp38-cp38-win_amd64.whl", hash = "sha256:b89c115d877892a512f79a8114564fb435943b59067615894c3b13cd3e1fa107"}, - {file = "protobuf-5.29.3-cp39-cp39-win32.whl", hash = "sha256:0eb32bfa5219fc8d4111803e9a690658aa2e6366384fd0851064b963b6d1f2a7"}, - {file = "protobuf-5.29.3-cp39-cp39-win_amd64.whl", hash = "sha256:6ce8cc3389a20693bfde6c6562e03474c40851b44975c9b2bf6df7d8c4f864da"}, - {file = "protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f"}, - {file = "protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620"}, -] - -[[package]] -name = "psutil" -version = "7.1.3" -description = "Cross-platform lib for process and system monitoring." -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc"}, - {file = "psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0"}, - {file = "psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7"}, - {file = "psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251"}, - {file = "psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa"}, - {file = "psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee"}, - {file = "psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353"}, - {file = "psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b"}, - {file = "psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9"}, - {file = "psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f"}, - {file = "psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7"}, - {file = "psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264"}, - {file = "psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab"}, - {file = "psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880"}, - {file = "psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3"}, - {file = "psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b"}, - {file = "psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd"}, - {file = "psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1"}, - {file = "psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74"}, -] - -[package.extras] -dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""] -test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "setuptools", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""] - -[[package]] -name = "pyasn1" -version = "0.6.1" -description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, - {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.1" -description = "A collection of ASN.1-based protocols modules" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, - {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, -] - -[package.dependencies] -pyasn1 = ">=0.4.6,<0.7.0" - -[[package]] -name = "pycparser" -version = "2.22" -description = "C parser in Python" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\"" -files = [ - {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, - {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, -] - -[[package]] -name = "pyopenssl" -version = "25.0.0" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "pyOpenSSL-25.0.0-py3-none-any.whl", hash = "sha256:424c247065e46e76a37411b9ab1782541c23bb658bf003772c3405fbaa128e90"}, - {file = "pyopenssl-25.0.0.tar.gz", hash = "sha256:cd2cef799efa3936bb08e8ccb9433a575722b9dd986023f1cabc4ae64e9dac16"}, -] - -[package.dependencies] -cryptography = ">=41.0.5,<45" -typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} - -[package.extras] -docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] -test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] - -[[package]] -name = "pyparsing" -version = "3.2.1" -description = "pyparsing module - Classes and methods to define and execute parsing grammars" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pyparsing-3.2.1-py3-none-any.whl", hash = "sha256:506ff4f4386c4cec0590ec19e6302d3aedb992fdc02c761e90416f158dacf8e1"}, - {file = "pyparsing-3.2.1.tar.gz", hash = "sha256:61980854fd66de3a90028d679a954d5f2623e83144b5afe5ee86f43d762e5f0a"}, -] - -[package.extras] -diagrams = ["jinja2", "railroad-diagrams"] - -[[package]] -name = "python-avatars" -version = "1.4.1" -description = "SVG avatar library for Python" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "python_avatars-1.4.1-py3-none-any.whl", hash = "sha256:ef97b1f8ac23583367705f876fbbac1cc118435982532d882ccc788e95663722"}, - {file = "python_avatars-1.4.1.tar.gz", hash = "sha256:133dc0e1dfd778f0287aa6b6697da2677aeb3ce985ebf908205068e963165b0e"}, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pytz" -version = "2025.1" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57"}, - {file = "pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "redis" -version = "6.4.0" -description = "Python client for Redis database and key-value store" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f"}, - {file = "redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010"}, -] - -[package.dependencies] -async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} - -[package.extras] -hiredis = ["hiredis (>=3.2.0)"] -jwt = ["pyjwt (>=2.9.0)"] -ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] - -[[package]] -name = "regex" -version = "2024.11.6" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, - {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, - {file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"}, - {file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"}, - {file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"}, - {file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"}, - {file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"}, - {file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"}, - {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"}, - {file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"}, - {file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"}, - {file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"}, - {file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"}, - {file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"}, - {file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"}, - {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, -] - -[[package]] -name = "requests" -version = "2.32.4" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -description = "OAuthlib authentication support for Requests." -optional = false -python-versions = ">=3.4" -groups = ["main"] -files = [ - {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, - {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, -] - -[package.dependencies] -oauthlib = ">=3.0.0" -requests = ">=2.0.0" - -[package.extras] -rsa = ["oauthlib[signedtoken] (>=3.0.0)"] - -[[package]] -name = "rsa" -version = "4.9" -description = "Pure-Python RSA implementation" -optional = false -python-versions = ">=3.6,<4" -groups = ["main"] -files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, -] - -[package.dependencies] -pyasn1 = ">=0.1.3" - -[[package]] -name = "sentry-sdk" -version = "2.25.1" -description = "Python client for Sentry (https://sentry.io)" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "sentry_sdk-2.25.1-py2.py3-none-any.whl", hash = "sha256:60b016d0772789454dc55a284a6a44212044d4a16d9f8448725effee97aaf7f6"}, - {file = "sentry_sdk-2.25.1.tar.gz", hash = "sha256:f9041b7054a7cf12d41eadabe6458ce7c6d6eea7a97cfe1b760b6692e9562cf0"}, -] - -[package.dependencies] -certifi = "*" -urllib3 = ">=1.26.11" - -[package.extras] -aiohttp = ["aiohttp (>=3.5)"] -anthropic = ["anthropic (>=0.16)"] -arq = ["arq (>=0.23)"] -asyncpg = ["asyncpg (>=0.23)"] -beam = ["apache-beam (>=2.12)"] -bottle = ["bottle (>=0.12.13)"] -celery = ["celery (>=3)"] -celery-redbeat = ["celery-redbeat (>=2)"] -chalice = ["chalice (>=1.16.0)"] -clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] -django = ["django (>=1.8)"] -falcon = ["falcon (>=1.4)"] -fastapi = ["fastapi (>=0.79.0)"] -flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] -grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] -http2 = ["httpcore[http2] (==1.*)"] -httpx = ["httpx (>=0.16.0)"] -huey = ["huey (>=2)"] -huggingface-hub = ["huggingface_hub (>=0.22)"] -langchain = ["langchain (>=0.0.210)"] -launchdarkly = ["launchdarkly-server-sdk (>=9.8.0)"] -litestar = ["litestar (>=2.0.0)"] -loguru = ["loguru (>=0.5)"] -openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] -openfeature = ["openfeature-sdk (>=0.7.1)"] -opentelemetry = ["opentelemetry-distro (>=0.35b0)"] -opentelemetry-experimental = ["opentelemetry-distro"] -pure-eval = ["asttokens", "executing", "pure_eval"] -pymongo = ["pymongo (>=3.1)"] -pyspark = ["pyspark (>=2.4.4)"] -quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] -rq = ["rq (>=0.6)"] -sanic = ["sanic (>=0.8)"] -sqlalchemy = ["sqlalchemy (>=1.2)"] -starlette = ["starlette (>=0.19.1)"] -starlite = ["starlite (>=1.48)"] -statsig = ["statsig (>=0.55.3)"] -tornado = ["tornado (>=6)"] -unleash = ["UnleashClient (>=6.0.1)"] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main", "dev"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "sqlparse" -version = "0.5.3" -description = "A non-validating SQL parser." -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"}, - {file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"}, -] - -[package.extras] -dev = ["build", "hatch"] -doc = ["sphinx"] - -[[package]] -name = "stripe" -version = "11.5.0" -description = "Python bindings for the Stripe API" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "stripe-11.5.0-py2.py3-none-any.whl", hash = "sha256:3b2cd47ed3002328249bff5cacaee38d5e756c3899ab425d3bd07acdaf32534a"}, - {file = "stripe-11.5.0.tar.gz", hash = "sha256:bc3e0358ffc23d5ecfa8aafec1fa4f048ee8107c3237bcb00003e68c8c96fa02"}, -] - -[package.dependencies] -requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} -typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} - -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -description = "Fast, Extensible Progress Meter" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, - {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[package.extras] -dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] -discord = ["requests"] -notebook = ["ipywidgets (>=6)"] -slack = ["slack-sdk"] -telegram = ["requests"] - -[[package]] -name = "tweepy" -version = "4.15.0" -description = "Twitter library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "tweepy-4.15.0-py3-none-any.whl", hash = "sha256:64adcea317158937059e4e2897b3ceb750b0c2dd5df58938c2da8f7eb3b88e6a"}, - {file = "tweepy-4.15.0.tar.gz", hash = "sha256:1345cbcdf0a75e2d89f424c559fd49fda4d8cd7be25cd5131e3b57bad8a21d76"}, -] - -[package.dependencies] -oauthlib = ">=3.2.0,<4" -requests = ">=2.27.0,<3" -requests-oauthlib = ">=1.2.0,<3" - -[package.extras] -async = ["aiohttp (>=3.7.3,<4)", "async-lru (>=1.0.3,<3)"] -dev = ["coverage (>=4.4.2)", "coveralls (>=2.1.0)", "tox (>=3.21.0)"] -docs = ["myst-parser (==0.15.2)", "readthedocs-sphinx-search (==0.1.1)", "sphinx (==4.2.0)", "sphinx-hoverxref (==0.7b1)", "sphinx-tabs (==3.2.0)", "sphinx_rtd_theme (==1.0.0)"] -socks = ["requests[socks] (>=2.27.0,<3)"] -test = ["urllib3 (<2)", "vcrpy (>=1.10.3)"] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] -markers = {dev = "python_version == \"3.10\""} - -[[package]] -name = "tzdata" -version = "2025.1" -description = "Provider of IANA time zone data" -optional = false -python-versions = ">=2" -groups = ["main"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639"}, - {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"}, -] - -[[package]] -name = "uritemplate" -version = "4.1.1" -description = "Implementation of RFC 6570 URI Templates" -optional = false -python-versions = ">=3.6" -groups = ["main"] -files = [ - {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, - {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, -] - -[[package]] -name = "urllib3" -version = "2.6.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, - {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, -] - -[package.extras] -brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] - -[[package]] -name = "uvicorn" -version = "0.34.0" -description = "The lightning-fast ASGI server." -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"}, - {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"}, -] - -[package.dependencies] -click = ">=7.0" -h11 = ">=0.8" -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[package.extras] -standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] - -[[package]] -name = "virtualenv" -version = "20.29.2" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a"}, - {file = "virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<5" - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] - -[[package]] -name = "webencodings" -version = "0.5.1" -description = "Character encoding aliases for legacy web content" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, - {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, -] - -[[package]] -name = "whitenoise" -version = "6.9.0" -description = "Radically simplified static file serving for WSGI applications" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "whitenoise-6.9.0-py3-none-any.whl", hash = "sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df"}, - {file = "whitenoise-6.9.0.tar.gz", hash = "sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609"}, -] - -[package.extras] -brotli = ["brotli"] - -[metadata] -lock-version = "2.1" -python-versions = "^3.10" -content-hash = "e27798cb7b36d69c67693ec009aa7f3f828552ee23a4a99494cede1ab7efb5a6" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f7736e7..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,66 +0,0 @@ -[tool.poetry] -name = "education-website" -version = "0.1.0" -description = "Alpha One Labs Educational Platform" -authors = ["Alpha One Labs "] -packages = [ - { include = "web" } -] - -[tool.poetry.dependencies] -python = "^3.10" -django = "^5.1" -django-environ = "^0.11.2" -django-simple-captcha = "^0.5.20" -requests = "^2.32.4" -djlint = "^1.36.4" -stripe = "^11.4.1" -google-auth-oauthlib = "^1.2.0" -google-auth-httplib2 = "^0.2.0" -google-api-python-client = "^2.118.0" -icalendar = "^5.0.11" -whitenoise = "^6.8.2" -django-allauth = "^65.3.1" -django-storages = "^1.14.4" -django-markdownx = "^4.0.7" -django-browser-reload = "^1.18.0" -python-avatars = "^1.4.1" -cryptography = "^44.0.2" -tweepy = "^4.15.0" -pillow = "^12.1.1" -uvicorn = "^0.34.0" -sentry-sdk = "^2.25.1" -pyopenssl = "^25.0.0" -oauth2client = "4.1.3" -bleach = "^6.2.0" -channels = "^4.3.1" -channels-redis = "^4.3.0" -redis = "^6.4.0" -mysqlclient = "^2.2.4" -psutil = "^7.1.3" - -[tool.poetry.group.dev.dependencies] -djlint = "^1.34.1" -pre-commit = "^3.6.0" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" - -[tool.djlint] -profile = "django" -indent = 2 -blank_line_after_tag = "load,extends" -close_void_tags = true -format_css = true -format_js = true - -[tool.black] -line-length = 120 -target-version = ['py312'] -include = '\.pyi?$' - -[tool.isort] -profile = "ruff" -multi_line_output = 3 -line_length = 120 diff --git a/remove_navbar.py b/remove_navbar.py deleted file mode 100644 index d5fbf6a..0000000 --- a/remove_navbar.py +++ /dev/null @@ -1,19 +0,0 @@ -filepath = "web/templates/base.html" -with open(filepath, "r") as f: - lines = f.readlines() - -new_lines = [] -skip = False -for line in lines: - if '
{ + const code = editor.getValue(); + const stdin = stdinEl.value; + const language = langSel.value; + + if (!code.trim()) { + outputEl.textContent = "🛑 Please type some code first."; + return; + } + outputEl.textContent = "Running…"; + runBtn.disabled = true; + + const langExtMap = { + python: "py", + javascript: "js", + c: "c", + cpp: "cpp", + }; + const ext = langExtMap[language] || "txt"; + + fetch("https://emkc.org/api/v2/piston/execute", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + language: language, + version: "*", + files: [{ name: `main.${ext}`, content: code }], + stdin: stdin, + args: [], + }), + }) + .then((res) => res.json()) + .then((data) => { + let out = ""; + const run = data.run || {}; + if (run.stderr) out += `ERROR:\n${run.stderr}\n`; + if (run.stdout) out += run.stdout; + if (!out && run.output) out += run.output; + outputEl.textContent = out || "[no output]"; + }) + .catch((err) => { + outputEl.textContent = `Request failed: ${err.message}`; + }) + .finally(() => { + runBtn.disabled = false; + }); +}); diff --git a/web/virtual_lab/static/virtual_lab/js/common.js b/static/virtual_lab/js/common.js similarity index 100% rename from web/virtual_lab/static/virtual_lab/js/common.js rename to static/virtual_lab/js/common.js diff --git a/web/virtual_lab/static/virtual_lab/js/physics_electrical_circuit.js b/static/virtual_lab/js/physics_electrical_circuit.js similarity index 100% rename from web/virtual_lab/static/virtual_lab/js/physics_electrical_circuit.js rename to static/virtual_lab/js/physics_electrical_circuit.js diff --git a/web/virtual_lab/static/virtual_lab/js/physics_inclined.js b/static/virtual_lab/js/physics_inclined.js similarity index 100% rename from web/virtual_lab/static/virtual_lab/js/physics_inclined.js rename to static/virtual_lab/js/physics_inclined.js diff --git a/web/virtual_lab/static/virtual_lab/js/physics_mass_spring.js b/static/virtual_lab/js/physics_mass_spring.js similarity index 100% rename from web/virtual_lab/static/virtual_lab/js/physics_mass_spring.js rename to static/virtual_lab/js/physics_mass_spring.js diff --git a/web/virtual_lab/static/virtual_lab/js/physics_pendulum.js b/static/virtual_lab/js/physics_pendulum.js similarity index 100% rename from web/virtual_lab/static/virtual_lab/js/physics_pendulum.js rename to static/virtual_lab/js/physics_pendulum.js diff --git a/web/virtual_lab/static/virtual_lab/js/physics_projectile.js b/static/virtual_lab/js/physics_projectile.js similarity index 100% rename from web/virtual_lab/static/virtual_lab/js/physics_projectile.js rename to static/virtual_lab/js/physics_projectile.js diff --git a/virtual_lab/chemistry/index.html b/virtual_lab/chemistry/index.html new file mode 100644 index 0000000..f9d808c --- /dev/null +++ b/virtual_lab/chemistry/index.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/virtual_lab/chemistry/ph-indicator/index.html b/virtual_lab/chemistry/ph-indicator/index.html new file mode 100644 index 0000000..cee0209 --- /dev/null +++ b/virtual_lab/chemistry/ph-indicator/index.html @@ -0,0 +1,661 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

pH Indicator

+
+ +
+ + + +
+ Enter a pH value (0–14) and click Update to see the color change. +
+
+
+ +
+ + + +
+
+
+ +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + + diff --git a/virtual_lab/chemistry/precipitation/index.html b/virtual_lab/chemistry/precipitation/index.html new file mode 100644 index 0000000..9da1d22 --- /dev/null +++ b/virtual_lab/chemistry/precipitation/index.html @@ -0,0 +1,659 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Precipitation Reaction

+
+ +
+ + + +
+ Click Add Reagent to begin. +
+
+
+ +
+ + + + + + +
+
+
+ +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + + diff --git a/virtual_lab/chemistry/reaction-rate/index.html b/virtual_lab/chemistry/reaction-rate/index.html new file mode 100644 index 0000000..a67ac30 --- /dev/null +++ b/virtual_lab/chemistry/reaction-rate/index.html @@ -0,0 +1,656 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Reaction Rate

+
+ +
+ + + +
+ Elapsed Time: + 0 s +
+
+ Set the concentration and start to see the reaction proceed... +
+
+ +
+
+ + +
+
+ +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + + diff --git a/web/templates/base.html b/virtual_lab/chemistry/solubility/index.html similarity index 71% rename from web/templates/base.html rename to virtual_lab/chemistry/solubility/index.html index 7dc1b19..d82d45c 100644 --- a/web/templates/base.html +++ b/virtual_lab/chemistry/solubility/index.html @@ -1,4 +1,6 @@ -{% load static %} + + + @@ -14,10 +16,10 @@ content="education, open source, courses, learning, Alpha One Labs" /> - - + + - {% block title %} Alpha One Labs - Open Source Education Platform {% endblock title %} + Alpha One Labs - Open Source Education Platform @@ -162,43 +164,126 @@ box-sizing: inherit; } - {% block extra_head %} {% endblock extra_head %} - + + - {% if messages %} -
- {% for message in messages %} -
-
- {% if message.tags == 'error' %} - - {% elif message.tags == 'success' %} - - {% elif message.tags == 'warning' %} - - {% else %} - - {% endif %} -
-

{{ message }}

- + +
+ Dissolved: + 0 g +
+
+ Solution is unsaturated. Add more solute to test saturation. +
+
+ +
- {% endfor %} + +
- {% endif %} {% block extra_body %} {% block content %} {% endblock content %} {% endblock extra_body %} {% block base_footer %} + + + + +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+ + + + + + + - {% endblock base_footer %} + - {% block extra_js %} {% endblock extra_js %} - + + diff --git a/virtual_lab/chemistry/titration/index.html b/virtual_lab/chemistry/titration/index.html new file mode 100644 index 0000000..0d73cbc --- /dev/null +++ b/virtual_lab/chemistry/titration/index.html @@ -0,0 +1,680 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Acid-Base Titration

+
+ +
+ + + + + +
+ Titrant Added: + 0 mL +
+
+ Adjust the controls and start titration to see hints here... +
+
+ +
+
+ +
+ + +
+
+
+ +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + + diff --git a/virtual_lab/code-editor/index.html b/virtual_lab/code-editor/index.html new file mode 100644 index 0000000..f4a71fb --- /dev/null +++ b/virtual_lab/code-editor/index.html @@ -0,0 +1,684 @@ + + + + + + + + + + + + + + + + + Code Editor – Alpha Science Lab + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+

Interactive Code Editor

+ +
print("Hello, world!")
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+

Output:

+

+    
+
+ +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + + + diff --git a/virtual_lab/index.html b/virtual_lab/index.html new file mode 100644 index 0000000..6cde3e0 --- /dev/null +++ b/virtual_lab/index.html @@ -0,0 +1,657 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/virtual_lab/physics/circuit/index.html b/virtual_lab/physics/circuit/index.html new file mode 100644 index 0000000..c37b788 --- /dev/null +++ b/virtual_lab/physics/circuit/index.html @@ -0,0 +1,763 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Basic Electrical Circuit

+

+ Build a simple RC circuit: a battery \(V_0\), a resistor \(R\), and a capacitor \(C\). Adjust \(V_0\), \(R\), and \(C\), then click “Start” to watch the capacitor charge. Observe real‐time \(V_C(t)\), \(I(t)\), and a live graph of capacitor voltage over time. After \(5\tau\), a quiz appears. +

+
+
+ +
+ +
+
+ + + 5.0 V +
+
+ + + 100 Ω +
+
+ + + 100 µF +
+
+ + + +
+
+ +
+ + +
+

+ Time: 0.00 s +

+

+ Voltage \(V_C\): 0.00 V +

+

+ Current \(I\): 0.00 A +

+

+ Time Constant \(\tau\): 0.01 s +

+
+
+ + +
+ +
+ +
+

Capacitor Voltage vs Time

+ +
+ +
+

Current vs Time

+ +
+
+
+
+
+
+ + + + + +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/inclined/index.html b/virtual_lab/physics/inclined/index.html new file mode 100644 index 0000000..ecd7677 --- /dev/null +++ b/virtual_lab/physics/inclined/index.html @@ -0,0 +1,785 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Inclined Plane Dynamics

+

+ Drag the block to any starting point, adjust angle, friction, and mass. + Click “Launch” to let it slide, watch live readouts, energy bars, force vectors, and a real-time graph. + Once it reaches the bottom, a short quiz will appear. +

+
+
+
+
+
+ + + 30° +
+
+ + + 0.00 +
+
+ + + 1.0 kg +
+
+ + + +
+
+ +
+ + +
+

+ Distance ↓: 0.00 m +

+

+ Speed: 0.00 m/s +

+

+ Accel: 0.00 m/s² +

+

+ PE: 0.00 J +

+

+ KE: 0.00 J +

+
+
+ +
+
+
+ mg sin α +
+
+
+ Normal (mg cos α) +
+
+
+ Friction (μ mg cos α) +
+
+
+ +
+ +
+

Position vs Time

+ +
+ +
+
+

Potential Energy

+
+ +
+
+
+

Kinetic Energy

+
+ +
+
+
+ + +
+
+
+
+
+ + + + + +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/mass_spring/index.html b/virtual_lab/physics/mass_spring/index.html new file mode 100644 index 0000000..87cdd28 --- /dev/null +++ b/virtual_lab/physics/mass_spring/index.html @@ -0,0 +1,784 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 4 +

+
    + +
+
+ + + +
+
+
+ +
+
+
+

Mass–Spring Oscillation

+

+ Drag the mass horizontally to set its initial displacement. Adjust the spring constant \(k\) and mass \(m\). + Click “Start” to see the mass oscillate. A live Position vs. Time graph and numeric readouts will update in real time. + After one full oscillation, a post-lab quiz will appear. +

+
+
+ +
+ +
+
+ + + 25 +
+
+ + + 1.0 kg +
+
+ + + 0.20 m +
+
+ + + +
+
+ +
+ + +
+

+ Time: 0.00 s +

+

+ Position: 0.00 m +

+

+ Velocity: 0.00 m/s +

+

+ Acceleration: 0.00 m/s² +

+

+ Potential (½kx²): 0.00 J +

+

+ Kinetic (½mv²): 0.00 J +

+
+
+ + +
+ +
+ +
+

Position vs Time

+ +
+ +
+
+

Potential Energy

+
+ +
+
+
+

Kinetic Energy

+
+ +
+
+
+
+
+
+
+
+ + + + + +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/pendulum/index.html b/virtual_lab/physics/pendulum/index.html new file mode 100644 index 0000000..f1e033f --- /dev/null +++ b/virtual_lab/physics/pendulum/index.html @@ -0,0 +1,733 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 5 +

+
    + +
+
+ + + +
+
+
+ +
+
+ +
+

Pendulum Motion

+

+ Follow the animated tutorial above, then start the simulation. Watch the pendulum swing and see its trail. Numerical readouts appear at top‐left. +

+
+ +
+ + + +
+
+ t = 0.00 s +
+
+ θ = 0.0° +
+
+ v = 0.0 m/s +
+
+ +
+

θ vs t

+ + +
+
+ + +
+
+
Potential Energy
+
+
+
+
+
+
Kinetic Energy
+
+
+
+
+
+ + +
+
+ + + 1.0 m +
+ + +
+ + +
+
+
+ + + +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + diff --git a/virtual_lab/physics/projectile/index.html b/virtual_lab/physics/projectile/index.html new file mode 100644 index 0000000..e497aa2 --- /dev/null +++ b/virtual_lab/physics/projectile/index.html @@ -0,0 +1,764 @@ + + + + + + + + + + + + + + + + Alpha One Labs - Open Source Education Platform + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+ +
+ +
+
+

+ Step 1 of 5 +

+
    + +
+
+ + + +
+
+
+ +
+
+ +
+

Projectile Motion

+

+ Click‐and‐drag from the launch pad (white circle) at left to set speed and angle. +
+ Release to fire. Adjust gravity or wind on the fly. + Watch the path and vectors, and see “y vs x” plotted live. +

+
+ +
+ +
+ + + +
+ + Launch Pad +
+ +
+
+ t = 0.00 s +
+
+ x = 0.00 m +
+
+ y = 0.00 m +
+
+ vₓ = 0.00 m/s +
+
+ v_y = 0.00 m/s +
+
+ +
+ +
+

Trajectory: y vs x

+ + +
+
+ + +
+
Mid-Flight Controls:
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + + +
+
+
+ + + +
+ +
+
+ © 2026 Alpha One Labs. All rights reserved. +
+
+
+ + + + + + + + + + + + diff --git a/web/__init__.py b/web/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/web/settings.py b/web/settings.py deleted file mode 100644 index a353f24..0000000 --- a/web/settings.py +++ /dev/null @@ -1,505 +0,0 @@ -import logging -import os -import sys -from pathlib import Path - -import environ -import sentry_sdk -from cryptography.fernet import Fernet -from django.core.exceptions import DisallowedHost -from sentry_sdk.integrations.django import DjangoIntegration -from sentry_sdk.integrations.logging import LoggingIntegration - -BASE_DIR = Path(__file__).resolve().parent.parent - -env = environ.Env() - -env_file = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env") - - -# Set encryption key for secure messaging; in production, this must come from the environment -MESSAGE_ENCRYPTION_KEY = env.str("MESSAGE_ENCRYPTION_KEY", default=Fernet.generate_key()).strip() -SECURE_MESSAGE_KEY = MESSAGE_ENCRYPTION_KEY - -if os.path.exists(env_file): - environ.Env.read_env(env_file) -else: - print("No .env file found.") - -# Re-initialize / initialize Sentry AFTER environment variables are loaded so DSN is present here. -SENTRY_DSN = env.str("SENTRY_DSN", default="") -if SENTRY_DSN: - # Capture WARNING+ as breadcrumbs and ERROR+ as events - sentry_logging = LoggingIntegration( - level=getattr(logging, os.getenv("SENTRY_LOG_LEVEL", "INFO").upper(), logging.INFO), - event_level=logging.ERROR, - ) - sentry_sdk.init( - dsn=SENTRY_DSN, - integrations=[DjangoIntegration(), sentry_logging], - environment=env.str("ENVIRONMENT", default="development"), - send_default_pii=True, - traces_sample_rate=float(os.getenv("SENTRY_TRACES_SAMPLE_RATE", 0.0)), # set >0 to enable performance - # Do not send Invalid Host (DisallowedHost) errors to Sentry - ignore_errors=(DisallowedHost,), - ) -else: - # Helpful notice for ops without breaking startup - print("Sentry DSN not configured; error events will not be sent.") - -SECRET_KEY = env.str("SECRET_KEY", default="django-insecure-5kyff0s@l_##j3jawec5@b%!^^e(j7v)ouj4b7q6kru#o#a)o3") -# Debug settings -ENVIRONMENT = env.str("ENVIRONMENT", default="development") - -# Default DEBUG to False for security -DEBUG = False - -# Only enable DEBUG in local environment and only if DJANGO_DEBUG is True -if ENVIRONMENT == "development": - DEBUG = True - -# Detect test environment and set DEBUG=True to use local media path -if "test" in sys.argv: - TESTING = True - DEBUG = True -else: - TESTING = False - -PA_USER = "alphaonelabs99282llkb" -PA_HOST = PA_USER + ".pythonanywhere.com" -PA_WSGI = "/var/www/" + PA_USER + "_pythonanywhere_com_wsgi.py" -PA_SOURCE_DIR = "/home/" + PA_USER + "/web" - -# Social Media Settings -TWITTER_USERNAME = "alphaonelabs" -TWITTER_API_KEY = os.getenv("TWITTER_API_KEY") -TWITTER_API_SECRET_KEY = os.getenv("TWITTER_API_SECRET_KEY") -TWITTER_ACCESS_TOKEN = os.getenv("TWITTER_ACCESS_TOKEN") -TWITTER_ACCESS_TOKEN_SECRET = os.getenv("TWITTER_ACCESS_TOKEN_SECRET") - -# Production settings -if not DEBUG: - # SECURE_SSL_REDIRECT = True - # adding this to prevent redirect loop - SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") - SESSION_COOKIE_SECURE = True - CSRF_COOKIE_SECURE = True - SECURE_HSTS_SECONDS = 31536000 # 1 year - SECURE_HSTS_INCLUDE_SUBDOMAINS = True - SECURE_HSTS_PRELOAD = True - SECURE_BROWSER_XSS_FILTER = True - SECURE_CONTENT_TYPE_NOSNIFF = True - SECURE_REDIRECT_EXEMPT = [] - SECURE_SSL_HOST = "alphaonelabs.com" - SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" - -# Allow hosts list can be overridden via .env (comma-separated) while providing a strong default. -ALLOWED_HOSTS = env.list( - "ALLOWED_HOSTS", - default=[ - "alphaonelabs99282llkb.pythonanywhere.com", - "0.0.0.0", - "127.0.0.1", - "localhost", - "alphaonelabs.com", - ".alphaonelabs.com", - ], -) - -# CSRF trusted origins can also be overridden through .env (comma-separated). -CSRF_TRUSTED_ORIGINS = env.list( - "CSRF_TRUSTED_ORIGINS", - default=[ - "https://alphaonelabs.com", - "https://www.alphaonelabs.com", - "http://127.0.0.1:8000", - "http://localhost:8000", - ], -) - -# Timezone settings -TIME_ZONE = "America/New_York" -USE_TZ = True - -# Error handling -handler404 = "django.views.defaults.page_not_found" -# Custom handler for 429 (too many requests) -# handler429 = "web.views.custom_429" - -# Admin notification settings -ADMINS = [("Admin", os.getenv("EMAIL_FROM"))] -SERVER_EMAIL = os.getenv("EMAIL_FROM") # Email address error messages come from - -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "django.contrib.sites", - "django.contrib.humanize", - "channels", - "allauth", - "allauth.account", - "captcha", - "markdownx", - "web", - "web.virtual_lab.apps.VirtualLabConfig", -] - -if DEBUG and not TESTING: - INSTALLED_APPS.append("django_browser_reload") - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - # Compress responses to reduce payload size - "django.middleware.gzip.GZipMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.locale.LocaleMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "allauth.account.middleware.AccountMiddleware", - # "web.middleware.GlobalExceptionMiddleware", -] - -if DEBUG and not TESTING: - MIDDLEWARE.insert(-2, "django_browser_reload.middleware.BrowserReloadMiddleware") - -ROOT_URLCONF = "web.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [BASE_DIR / "web/templates"], - # Use cached loaders in production to avoid repeated template parsing - "APP_DIRS": DEBUG, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - **( - {} - if DEBUG - else { - "loaders": [ - ( - "django.template.loaders.cached.Loader", - [ - "django.template.loaders.filesystem.Loader", - "django.template.loaders.app_directories.Loader", - ], - ) - ] - } - ), - }, - }, -] - -CAPTCHA_FONT_SIZE = 28 -CAPTCHA_IMAGE_SIZE = (150, 40) -CAPTCHA_LETTER_ROTATION = (-20, 20) -CAPTCHA_BACKGROUND_COLOR = "#f0f8ff" -CAPTCHA_FOREGROUND_COLOR = "#2f4f4f" -CAPTCHA_NOISE_FUNCTIONS = ("captcha.helpers.noise_arcs", "captcha.helpers.noise_dots") -CAPTCHA_FILTER_FUNCTIONS = ("captcha.helpers.post_smooth",) -CAPTCHA_2X_IMAGE = True -CAPTCHA_TEST_MODE = False - -WSGI_APPLICATION = "web.wsgi.application" - -# Add ASGI application configuration - -# Channels / Redis channel layer configuration (assumes a local Redis unless overridden) -REDIS_URL = env.str("REDIS_URL", default="redis://127.0.0.1:6379/0") -CHANNEL_LAYERS = { - "default": { - "BACKEND": "channels_redis.core.RedisChannelLayer", - "CONFIG": {"hosts": [REDIS_URL]}, - } -} - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", - # Persist DB connections to avoid reconnect overhead in production - "CONN_MAX_AGE": 300 if not DEBUG else 0, - } -} - -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - -SITE_ID = 1 -SITE_NAME = "AlphaOne Labs" -SITE_DOMAIN = "alphaonelabs.com" - -# Allauth settings -ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_USERNAME_REQUIRED = False # Since we're using email authentication -ACCOUNT_EMAIL_VERIFICATION = "mandatory" # Require email verification -ACCOUNT_LOGIN_METHODS = {"email"} -ACCOUNT_UNIQUE_EMAIL = True -ACCOUNT_PREVENT_ENUMERATION = True # Prevent user enumeration -ACCOUNT_USERNAME_MIN_LENGTH = 3 -ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True -ACCOUNT_SESSION_REMEMBER = None # Let user decide via checkbox -ACCOUNT_REMEMBER_ME_FIELD = "remember" # Match test field name -ACCOUNT_LOGIN_ON_PASSWORD_RESET = True -ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False -ACCOUNT_LOGOUT_ON_GET = True -ACCOUNT_SIGNUP_EMAIL_ENTER_TWICE = False -ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = False -ACCOUNT_OLD_PASSWORD_FIELD_ENABLED = True -ACCOUNT_EMAIL_AUTHENTICATION = True # Enable email authentication -ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = "index" -ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = "account_login" - -# Authentication backends -AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", - "allauth.account.auth_backends.AuthenticationBackend", -] - -# Authentication URLs -LOGIN_URL = "account_login" -LOGIN_REDIRECT_URL = "index" -LOGOUT_REDIRECT_URL = "index" - -ACCOUNT_RATE_LIMITS = { - "login_attempt": "5/5m", # 5 attempts per 5 minutes - "login_failed": "3/5m", # 3 failed attempts per 5 minutes - "signup": "5/h", # 5 signups per hour - "send_email": "5/5m", # 5 emails per 5 minutes - "change_email": "3/h", # 3 email changes per hour -} - -# Override allauth forms - -LANGUAGE_CODE = "en" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_TZ = True - - -STATIC_URL = "/static/" - - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - -""" -Media files configuration - -Previously MEDIA_ROOT for production was hard-coded to the legacy PythonAnywhere -path (/home/alphaonelabs99282llkb/web/media). That prevented the live server -from locating media we now place under the project directory on the new VPS. - -We switch to an environment-variable override with a sane default pointing to -/media for both dev and prod (unless a cloud storage backend is -configured later). This keeps URLs stable (/media/...) while aligning file -paths with the Nginx alias in ansible/nginx-http.conf.j2: - location /media/ { alias /home/django/education-website/media/; } - -If GS_BUCKET_NAME is set above, DEFAULT_FILE_STORAGE will override this with -Google Cloud Storage; in that case MEDIA_ROOT is less relevant. -""" -MEDIA_ROOT = env.str("MEDIA_ROOT", default=str(BASE_DIR / "media")) -MEDIA_URL = "/media/" - -STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") -STATICFILES_DIRS = [BASE_DIR / "static"] -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" - -# Caching configuration: fast in-memory cache by default; can be -# overridden via environment (e.g., django-redis) without code changes. -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.locmem.LocMemCache", - "LOCATION": "alphaonelabs-local", - "TIMEOUT": 300, - "OPTIONS": {"MAX_ENTRIES": 10000}, - } -} - -# Cache middleware settings (usable when wrapping views or enabling site-wide cache) -CACHE_MIDDLEWARE_ALIAS = "default" -CACHE_MIDDLEWARE_SECONDS = int(os.getenv("CACHE_MIDDLEWARE_SECONDS", "300")) -CACHE_MIDDLEWARE_KEY_PREFIX = os.getenv("CACHE_MIDDLEWARE_KEY_PREFIX", "aol") - -# Use cached sessions in normal runtime to reduce DB hits; keep default during tests -if not TESTING: - SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" - -# Drop noisy Invalid Host messages from logs entirely -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": { - "null": {"class": "logging.NullHandler"}, - # Ensure any accidental use of 'mail_admins' will be a no-op - "mail_admins": {"class": "logging.NullHandler"}, - }, - "loggers": { - # Django emits DisallowedHost on invalid/missing Host header; silence it - "django.security.DisallowedHost": { - "handlers": ["null"], - "level": "ERROR", - "propagate": False, - }, - # Do not email admins on request/server errors - "django.request": { - "handlers": ["null"], - "level": "ERROR", - "propagate": False, - }, - "django.server": { - "handlers": ["null"], - "level": "ERROR", - "propagate": False, - }, - }, -} - -# Email settings -if DEBUG: - EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - print("Using console email backend for development") - DEFAULT_FROM_EMAIL = "noreply@example.com" # Default for development - MAILGUN_SENDING_KEY = None # Not needed in development -else: - # Production email settings - EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" - MAILGUN_SENDING_KEY = env.str("MAILGUN_SENDING_KEY", default="") - # Optional: set MAILGUN_DOMAIN explicitly; otherwise inferred from DEFAULT_FROM_EMAIL - MAILGUN_DOMAIN = env.str("MAILGUN_DOMAIN", default="") or None - DEFAULT_FROM_EMAIL = env.str("EMAIL_FROM", default="noreply@alphaonelabs.com") - EMAIL_FROM = os.getenv("EMAIL_FROM") - -# Stripe settings -STRIPE_PUBLISHABLE_KEY = env("STRIPE_PUBLISHABLE_KEY", default="") -STRIPE_SECRET_KEY = env("STRIPE_SECRET_KEY", default="") -STRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="") - -# Social Media and Content API Settings -MAILCHIMP_API_KEY = env.str("MAILCHIMP_API_KEY", default="") -MAILCHIMP_LIST_ID = env.str("MAILCHIMP_LIST_ID", default="") - -INSTAGRAM_ACCESS_TOKEN = env.str("INSTAGRAM_ACCESS_TOKEN", default="") -FACEBOOK_ACCESS_TOKEN = env.str("FACEBOOK_ACCESS_TOKEN", default="") - -GITHUB_ACCESS_TOKEN = env.str("GITHUB_ACCESS_TOKEN", default="") -GITHUB_REPO = env.str("GITHUB_REPO", default="AlphaOneLabs/education-website") - -YOUTUBE_API_KEY = env.str("YOUTUBE_API_KEY", default="") -YOUTUBE_CHANNEL_ID = env.str("YOUTUBE_CHANNEL_ID", default="") - -TWITTER_USERNAME = env.str("TWITTER_USERNAME", default="alphaonelabs") - -# Slack Integration -SLACK_WEBHOOK_URL = env.str("SLACK_WEBHOOK_URL", default="") - -# Slack webhook for email notifications -EMAIL_SLACK_WEBHOOK = env.str("EMAIL_SLACK_WEBHOOK", default=SLACK_WEBHOOK_URL) - -LANGUAGES = [ - ("en", "English"), - ("es", "Spanish"), - ("fr", "French"), - ("de", "German"), - ("zh-hans", "Simplified Chinese"), -] - -LOCALE_PATHS = [ - BASE_DIR / "locale", -] - -USE_L10N = True - -if os.environ.get("DATABASE_URL"): - DATABASES = {"default": env.db()} - - # Only add MySQL-specific options if using MySQL - if DATABASES["default"]["ENGINE"] == "django.db.backends.mysql": - DATABASES["default"]["OPTIONS"] = { - "charset": "utf8mb4", - "sql_mode": ( - "STRICT_TRANS_TABLES," - "NO_ZERO_IN_DATE," - "NO_ZERO_DATE," - "ERROR_FOR_DIVISION_BY_ZERO," - "NO_ENGINE_SUBSTITUTION" - ), - "init_command": "SET NAMES 'utf8mb4' COLLATE 'utf8mb4_unicode_ci'", - } - - # Google Cloud Storage settings for media files in production - if os.environ.get("GS_BUCKET_NAME"): - DEFAULT_FILE_STORAGE = "storages.backends.gcloud.GoogleCloudStorage" - GS_BUCKET_NAME = os.environ.get("GS_BUCKET_NAME") - GS_PROJECT_ID = os.environ.get("GS_PROJECT_ID") - - # Get service account file path from .env - service_account_filename = env.str("SERVICE_ACCOUNT_FILE") - SERVICE_ACCOUNT_FILE = os.path.join(BASE_DIR, service_account_filename) - if os.path.exists(SERVICE_ACCOUNT_FILE): - from google.oauth2 import service_account - - GS_CREDENTIALS = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE) - else: - print(f"Warning: Service account file not found at {SERVICE_ACCOUNT_FILE}") - GS_CREDENTIALS = None - - GS_DEFAULT_ACL = "publicRead" - GS_QUERYSTRING_AUTH = False - GS_LOCATION = "media" # Store files in a media directory in the bucket - - -# Admin URL Configuration -ADMIN_URL = env.str("ADMIN_URL", default="a-dmin-url123") - -# Markdownx configuration -MARKDOWNX_MARKDOWN_EXTENSIONS = [ - "markdown.extensions.extra", - "markdown.extensions.codehilite", - "markdown.extensions.tables", - "markdown.extensions.toc", -] - -MARKDOWNX_URLS_PATH = "/markdownx/markdownify/" -MARKDOWNX_UPLOAD_URLS_PATH = "/markdownx/upload/" -MARKDOWNX_MEDIA_PATH = "markdownx/" # Path within MEDIA_ROOT - -USE_X_FORWARDED_HOST = True - -# GitHub API Token for fetching contributor data -GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") - - -# Allow per-environment override of secure cookie behavior (useful for staging without HTTPS). -CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=not DEBUG) -SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=not DEBUG) -GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "") diff --git a/web/static/img/image.png b/web/static/img/image.png deleted file mode 100644 index bdb6de16076893714d5fc8f3a81b934bd41ab8ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5111 zcmVPx#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91WS|281ONa40RR91X8-^I0B9SekpKV_VM#V5;Hk@-Jfg zn-)}6RS8rmTT0E!7^S{#n3r9;bSeFvcc%MHcAc!*)zw*9Q&ZD6%p;>NSVNUcRQ#wE zo0u4(o}OM*Qc_Z!ay#X_+qZ9qr=_JmQKfvcnHQA<^sIj3Q1q1{j`*GvY{reA! zr~Q%yNDcAZu@i$gHa0fKTYR?S{DOzLxw!>LN5|A`_VNAn zrAJ=hvMB*kKnN)0(AU?;n>PF4v~(Ws?Civ0VORV2?mw_+;lhQ6Z06;4+8_ZsKnN%r z8XDNr(h_gnv<2TyO~cQh*JDmhT-E2Ff3eTq-QE7R@K}-n6CebXUxBg0ettVJKO+-Y zR6N1S*KZbn^2w(k&6zXDk)>??c9R_r5Fs$+Y-weMcL3jH=iI|jpVs2|goHwW|6PZ? zyu8eRyAo7z$)y2A2o53tt-jlE!NWp|{3D~H3HcvUaMpgC$;kqU5Eugk1MIWa7xVZT zxV@ueFf=s0ckjLfyI+&P9RDSG03pB-^0%_Grn<{%Y3aDGt`0|YVk<&I{(H~-`SZug zku{|~N~=0K`0?MooSi558yOmugI=joS-A-g-V>6VmWJ--WXt?YP`RMo+y^K&HWodt zeTEh-T4ee=clTvernxP1uy@q$>l0L~ol54$t(1M$HBa%ih{)&bH*7kwY}qm^nI$B} zgYwo8coo$f6Frj}vvXGfh7mtS{;_fK54Ugk|7hjPm8Mc;ZDgYoK)QQ+FmW9n9r(w~ zSMb_(>%Vk%b~a%%XKVl>1jpFe1aI@(jvp2l;il$h91#&!y=lwV!)%sEVQGs3LZJdb zot<4cG%UO)D0uJxI668`P}s{v5@Hh&cn!rdMn*>1ciVPq9Mjg;KKN5;SodG|9}G}w z@}~oY05do^i0kX?aa;oT>Fzy${fCp2(_|5O%6-W;AOsX4FxECU0E{2z@%Xs5_8E?h z=9GQ**%v{xX1!-8cR>`L023g>+A;_i_4N%zeNpM_=XVU$7ZVkc2W0_5Kp_X|m04Tc z;9UWM)Bv-ryaIE%$$1A39Z{`b*>Hdefg$JmhDIEI_1bf9@4x)h)6-L~QL?fD5duTb z1ek!pAe@_*kD+%4f42AnUtiyS8#it=hVYSf(f}d^2L@yKhf6;a^8Z3s@*hb+K;l%b zF*Y&5+jscm!lGi_)YOcxUX7^Qlm&prxjD7Qls? zyNHVBRQUM#{$s&{1!8NAssZvVFdEp##uf(!1ycjeva)iVz)gCvfB!+{^vV+5x7j0wM&4oJn_ickmu+YMGFKR8&m<=FL8PK<&@o3?>_Z z2*GIwj``R3KM?W{nK*IckRFUW03rm3aE$-XUAUyt;n%9RY}vXW!WHk>1cU%X znzGimHhlS~(Ds0!;4gq<-Xx_^CqN*(6#1K)nNhm3%&crOA2V?I@=vXx_V<&O{Asnl zVC~`V`*@K&i0v98Y`%nN zQe#N3jErRtANd3WSX3_ua;jEm(p|QJURm&-y*M{F4?iv|!*TK4d(d6pHFxe@o8cvo z(1_HKmor9c49xf;9UUDsbH+^M;5Z&VdGZ7`H8mmVLcbhcrSCgCI#33mkD{WYQAcMd z^7LG4>#^A5Lnp^KJTXZgUlS8k;Fv&){2O894^zvPp!VOZc7PCY$N;msr3HU`{sNve=Uoh(_M)Jog;w=z z(v+E7Sb(lN4=G}Masx2_2rrX=w zQ%X!?zEo9JjlcZ+(XWTCEvg2H5F7%`Gca5@dFnKNXZm!tCVyKyJN%@w8lO6Q{;DV- znxZ$VehpP0*;renpuhlhk(W8u!9PI58Hz|oRoCFvd z8X~`KThW@;D^d8>2z24Xd368YJv1=TFZMY}15chhgVuX{NgADs2ZrHN#TSh{qJUfm zK#EFAMe{S{x}>ZO6+J9OlO~JT-jk-x%*+h!^xuw_FaHp1vf|N20?hq;sJ~x6V2q3m zk%fiDkbJRuGh%hTqP!gCgRsT9fq_938yAa6n=6SUz>KrAqkxHvk3+|f{}WA{=0*)T zC3#*I;*1y|ckbLl&>tUG28qyQfo<5Z@kApKU~Fw{Q1I>`^z+S|=zqUDhFn}GA?S9B zMpMb_5zAp%SeQi3Nmh0?0&`ZGg=Y=qPk_nH$^`QN7P(AvRcZ1c5kSa4K8?qhNXWf{ zJ`^1lB~e^)kqG&3_uGb2@7_fxPn|+DXU$T{?(&F`dJ>n5z*JS7{Dy?319-5$kWxSn z36S!JG-VbR<|ts-PPAhAaufpw*%vQdpp3Jb$Ui!o1M@T`ue@p2%$aEQsui#Jv*N+# zNWHhW7yH5j8*N=Zi4VJR<8WSXu5iGvu=9UZGSBdTg?x_n^z@|ClabZJOb$SiC@nG} zH{v7pmX;QD^XAV|{gE0Pn@~#19jTs?Br>TncD6R?{rBHTIxu0T3Qp-9a_4pms;zr2 zb=l*&38=qMx@iQdU!npJC>0QBB2e;;>x{+L(bk3@5X@H5xcvMAXzO!nLPnRDQUR%{ zu0|OdnT!%#@#hbbg*m->a6@AwN>Ar8K08_hlnO{;VIg{4UPdcBQZs@A1JNXxw`s#k z?TUj@`)GGc1tc;e67}`b>z3?ntkLflEC5~9a^_|WR4q>`Dn>OXN(1C+O$|!r(Kk7y zc7WMGC1_Zn3C)%ntNB{)_OXo}S3ifZlewySoQPMMl1eJK8Rk0>~{;frEtwt?cw* zgzV}0{*W+O<%_0HoifBv^yV&XF3>kh2Z>4nogBpu(Q4 z0zw2A7az}PFzx)t8|dw~-l7!`?1P0CFIh|*Mm+DPrlLwP$R2Hu)d{M$)>afBpTH7uq9r|M&76gt-*l!GS5I#*`sLquX~WdflNCU+vvWY#)J!W6VnGNSx6p<$dBSQV zG-Jk4eV1rlvIFVBz<{Wi+FmOh5HdQCjEY2LoIvL$!a#1;?AdfdV*SW?V+;%=>0D)X z4dOG*M$*MsWk327%8}!e;$oD`Fc>6LwQJU_mMQhApx1I3XV3U-0+^=~LkM+u3I~Kt zM71*XuFOqM(SrH&Ws(_D(|dR@tK?&2;}EfmRClLvKq4X{7~Atnu9MIVm@}8fk=o_C zbSa}nN-3l?`r zCLRC!%K0<=w*@U6K|iX7xs~?z zHi?&E%UgE%UJ8Qk%^z~(j%43LU*};T_TtBRWyfFr=z3m1R81U9Xoa)V`EclyFoUz0CY7(Za|2X zQ@C8gjq6E#g!Q7ETbs&(TO4$Bb!^myV+bIRODm3l|NVsq*z$v)mzNv;sI;^k?8Y2m zL8y(-mMzHC)Ere*Q~<}cE37GF4G`g9KRcV(5zC3rgtm)=v1S>JE?r=AOLH>sp~_AO zNJ`4j)YKzzV@G~oUMB1+#DU#X%LIZx7cZ~1=ESH^f2;w5ZC_!#5BY(i1B6vMZ9Z_o zAna#e()iD#PZOgkEw5t zfR0UOj-lfaraUOzA4dKMc>GM2B>&+65fz4q$C3l5&YY%G0r)~;Pktq7~BsiyY&0^$@tGUQAL$dCvlbrTpEXc%@iA|N%5*A3t(o&2TY5OFfH zva&Mt^70P&=ETWv*yv!8e8Kvo0|&9Cr4{uh1$cG{&(Lv*xb#OP?Bwawhxr*<)yf?C zD+&-%fi3j-MQ2K&hzcDJ?BY&(6whEU@|P1HkD^yo-Y4v0U~@C zs9V}{AKd3l^BqG`F-B+`jp<`Y*f(wf~Wf%KS(PMpNu>ki z-<_D4kO~b~YHVVqY7L06Aeot&ZBbE?>HYm=Q1Os(oNx9U{jv{}tiK-GHVlz4# z0>3$V`qRwp9I@oDN~At2nk;dDoJ4NIFD`Gtbt%7~utk5YzJrOeiKUj-&|<4n4H=p< zaV1mZFj#=vl@K3y7Yvo-+B>^zVHay4e;rHV0P{5 1: - code = parts[1].strip("/") - if code: - active_referral_codes.add(code) - elif "?ref=" in path: - # Format: /?ref=CODE or ?ref=CODE&other=params - parts = path.split("?ref=") - if len(parts) > 1: - code = parts[1].split("&")[0] # Handle additional parameters - if code: - active_referral_codes.add(code) - - # Get profiles with referrals and/or clicks - profiles_with_activity = Profile.objects.filter( - models.Q(referrals__isnull=False) # Has referrals - | models.Q(referral_code__in=active_referral_codes) # Has clicks - ).distinct() - - # Annotate with signups and enrollments - top_referrers = profiles_with_activity.annotate( - total_signups=models.Count("referrals", distinct=True), - total_enrollments=models.Count( - "referrals__user__enrollments", - filter=models.Q(referrals__user__enrollments__status="approved"), - distinct=True, - ), - ).order_by("-total_signups", "-total_enrollments")[ - :10 - ] # Get more and then sort by clicks - - # Add click counts manually since WebRequest.user is a CharField, not a ForeignKey - for referrer in top_referrers: - # Look for both new format /ref/CODE/ and old format ?ref=CODE - ref_code = referrer.referral_code - clicks = WebRequest.objects.filter( - models.Q(path__contains=f"/ref/{ref_code}/") | models.Q(path__contains=f"?ref={ref_code}") - ).count() - referrer.total_clicks = clicks - - # Re-sort to include click count in ranking - top_referrers = sorted( - top_referrers, key=lambda x: (x.total_signups, x.total_enrollments, x.total_clicks), reverse=True - )[ - :3 - ] # Take top 3 after sorting - - # Get current user's profile if authenticated - profile = request.user.profile if request.user.is_authenticated else None - - # Get recent courses - featured_courses = Course.objects.filter(status="published").order_by("-created_at")[:6] - - # Get featured goods - featured_goods = Goods.objects.filter(featured=True, is_available=True).order_by("-created_at")[:3] - - # Get current challenge - current_challenge_obj = Challenge.objects.filter( - start_date__lte=timezone.now(), end_date__gte=timezone.now() - ).first() - current_challenge = [current_challenge_obj] if current_challenge_obj else [] - - # Get latest blog post - latest_post = BlogPost.objects.filter(status="published").order_by("-published_at").first() - - # Get latest success story - latest_success_story = SuccessStory.objects.filter(status="published").order_by("-published_at").first() - - # Get last two waiting room requests - latest_waiting_room_requests = WaitingRoom.objects.filter(status="open").order_by("-created_at")[:2] - - # Global virtual classroom summary for homepage CTA - global_classroom = ( - VirtualClassroom.objects.filter(name__iexact="Global Virtual Classroom", course__isnull=True) - .order_by("-created_at") - .first() - ) - global_classroom_participants = 0 - if global_classroom: - global_classroom_participants = VirtualClassroomParticipant.objects.filter(classroom=global_classroom).count() - - # Get top latest 3 leaderboard users - try: - top_leaderboard_users, user_rank = get_leaderboard(request.user, period=None, limit=3) - except Exception: - logger = logging.getLogger(__name__) - logger.error("Error getting leaderboard data", exc_info=True) - top_leaderboard_users = [] - - # Get signup form if needed - form = None - if not request.user.is_authenticated or not request.user.profile.is_teacher: - form = TeacherSignupForm() - - # Get video count and subjects for the quick add video form - video_count = EducationalVideo.objects.count() - subjects = Subject.objects.all().order_by("order", "name") - - context = { - "profile": profile, - "featured_courses": featured_courses, - "featured_products": featured_goods, - "current_challenge": current_challenge, - "latest_post": latest_post, - "latest_success_story": latest_success_story, - "latest_waiting_room_requests": latest_waiting_room_requests, - "global_classroom": global_classroom, - "global_classroom_participants": global_classroom_participants, - "top_referrers": top_referrers, - "top_leaderboard_users": top_leaderboard_users, - "form": form, - "is_debug": settings.DEBUG, - "video_count": video_count, - "subjects": subjects, - } - if request.user.is_authenticated: - user_team_goals = ( - TeamGoal.objects.filter(Q(creator=request.user) | Q(members__user=request.user)) - .distinct() - .order_by("-created_at")[:3] - ) - - team_invites = TeamInvite.objects.filter(recipient=request.user, status="pending").select_related( - "goal", "sender" - ) - - context.update( - { - "user_team_goals": user_team_goals, - "team_invites": team_invites, - } - ) - - # Add courses that the user is teaching if they have any - teaching_courses = ( - Course.objects.filter(teacher=request.user) - .annotate( - view_count=Coalesce(Sum("web_requests__count"), 0), - enrolled_students=Count("enrollments", filter=Q(enrollments__status="approved")), - ) - .order_by("-created_at") - ) - - if teaching_courses.exists(): - context.update( - { - "teaching_courses": teaching_courses, - } - ) - return render(request, "index.html", context) - - -def signup_view(request): - """Custom signup view that properly handles referral codes.""" - if request.method == "POST": - form = UserRegistrationForm(request.POST, request=request) - if form.is_valid(): - form.save(request) - return redirect("account_email_verification_sent") - else: - # Initialize form with request to get referral code from session - form = UserRegistrationForm(request=request) - - # If there's no referral code in session but it's in the URL, store it - ref_code = request.GET.get("ref") - if ref_code and not request.session.get("referral_code"): - request.session["referral_code"] = ref_code - # Reinitialize form to pick up the new session value - form = UserRegistrationForm(request=request) - - return render( - request, - "account/signup.html", - { - "form": form, - "login_url": reverse("account_login"), - }, - ) - - -@login_required -def delete_account(request): - if request.method == "POST": - form = AccountDeleteForm(request.user, request.POST) - if form.is_valid(): - if request.POST.get("confirm"): - user = request.user - user.delete() - logout(request) - messages.success(request, _("Your account has been successfully deleted.")) - return redirect("index") - else: - form.add_error(None, _("You must confirm the account deletion.")) - else: - # Get all related objects that will be deleted - deleted_objects_collector = NestedObjects(using=router.db_for_write(request.user.__class__)) - deleted_objects_collector.collect([request.user]) - - # Transform the nested structure into something more user-friendly - to_delete = deleted_objects_collector.nested() - protected = deleted_objects_collector.protected - - # Format the collected objects in a user-friendly way - model_count = { - model._meta.verbose_name_plural: len(objs) for model, objs in deleted_objects_collector.model_objs.items() - } - - # Format as a list of tuples (model name, count) - formatted_count = [(name, count) for name, count in model_count.items()] - - form = AccountDeleteForm(request.user) - # Pass the deletion info to the template - return render( - request, - "account/delete_account.html", - {"form": form, "deleted_objects": to_delete, "protected": protected, "model_count": formatted_count}, - ) - - return render(request, "account/delete_account.html", {"form": form}) - - -@login_required -def delete_waiting_room(request, waiting_room_id): - """View for deleting a waiting room.""" - waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) - - # Only allow creator to delete - if request.user != waiting_room.creator: - messages.error(request, "You don't have permission to delete this waiting room.") - return redirect("waiting_room_detail", waiting_room_id=waiting_room_id) - - if request.method == "POST": - waiting_room.delete() - messages.success(request, f"Waiting room '{waiting_room.title}' has been deleted.") - return redirect("waiting_room_list") - - return render(request, "waiting_room/confirm_delete.html", {"waiting_room": waiting_room}) - - -@login_required -def all_leaderboards(request): - """ - Display all leaderboard types on a single page. - """ - # Get cached leaderboard data or fetch fresh data - global_entries, global_rank = get_cached_leaderboard_data(request.user, None, 10, "global_leaderboard", 60 * 60) - weekly_entries, weekly_rank = get_cached_leaderboard_data(request.user, "weekly", 10, "weekly_leaderboard", 60 * 15) - monthly_entries, monthly_rank = get_cached_leaderboard_data( - request.user, "monthly", 10, "monthly_leaderboard", 60 * 30 - ) - - # Get user points and challenge entries if authenticated non-teacher - challenge_entries = [] - user_points = None - - if request.user.is_authenticated and not request.user.profile.is_teacher: - user_points = get_user_points(request.user) - challenge_entries = get_cached_challenge_entries() - - context = create_leaderboard_context( - global_entries, - weekly_entries, - monthly_entries, - challenge_entries, - global_rank, - weekly_rank, - monthly_rank, - user_points["total"], - user_points["weekly"], - user_points["monthly"], - ) - return render(request, "leaderboards/leaderboards.html", context) - else: - context = create_leaderboard_context( - global_entries, - weekly_entries, - monthly_entries, - [], - global_rank, - weekly_rank, - monthly_rank, - None, - None, - None, - ) - return render(request, "leaderboards/leaderboards.html", context) - - -@login_required -def profile(request): - if request.method == "POST": - form = ProfileUpdateForm(request.POST, request.FILES, instance=request.user) - if form.is_valid(): - form.save() # Save the form data - request.user.profile.refresh_from_db() # Refresh to load updated profile - messages.success(request, "Profile updated successfully!") - return redirect("profile") - else: - for field, errors in form.errors.items(): - for error in errors: - messages.error(request, f"Error in {field}: {error}") - else: - form = ProfileUpdateForm(instance=request.user) - - badges = UserBadge.objects.filter(user=request.user).select_related("badge") - - context = { - "form": form, - "badges": badges, - } - - # Teacher-specific stats - if request.user.profile.is_teacher: - courses = Course.objects.filter(teacher=request.user) - total_students = sum(course.enrollments.filter(status="approved").count() for course in courses) - avg_rating = 0 - total_ratings = 0 - for course in courses: - course_ratings = course.reviews.all() - if course_ratings: - avg_rating += sum(review.rating for review in course_ratings) - total_ratings += len(course_ratings) - avg_rating = round(avg_rating / total_ratings, 1) if total_ratings > 0 else 0 - context.update( - { - "courses": courses, - "total_students": total_students, - "avg_rating": avg_rating, - } - ) - # Student-specific stats - else: - enrollments = Enrollment.objects.filter(student=request.user).select_related("course") - completed_courses = enrollments.filter(status="completed").count() - total_progress = 0 - progress_count = 0 - for enrollment in enrollments: - progress, _ = CourseProgress.objects.get_or_create(enrollment=enrollment) - if progress.completion_percentage is not None: - total_progress += progress.completion_percentage - progress_count += 1 - avg_progress = round(total_progress / progress_count) if progress_count > 0 else 0 - context.update( - { - "enrollments": enrollments, - "completed_courses": completed_courses, - "avg_progress": avg_progress, - } - ) - - # Get created calendars if applicable - created_calendars = request.user.created_calendars.prefetch_related("time_slots").order_by("-created_at") - context["created_calendars"] = created_calendars - - # *** Add Discount Codes *** - discount_codes = Discount.objects.filter(user=request.user, used=False, valid_until__gte=timezone.now()) - context["discount_codes"] = discount_codes - - return render(request, "profile.html", context) - - -@login_required -def create_course(request): - if request.method == "POST": - form = CourseForm(request.POST, request.FILES) - if form.is_valid(): - course = form.save(commit=False) - course.teacher = request.user - course.status = "published" # Set status to published - course.save() - form.save_m2m() # Save many-to-many relationships - - # Handle waiting room if course was created from one - if "waiting_room_data" in request.session: - waiting_room = get_object_or_404(WaitingRoom, id=request.session["waiting_room_data"]["id"]) - - # Update waiting room status and link to course - waiting_room.status = "fulfilled" - waiting_room.fulfilled_course = course - waiting_room.save(update_fields=["status", "fulfilled_course"]) - - # Send notifications to all participants - for participant in waiting_room.participants.all(): - messages.success( - request, - f"A new course matching your request has been created: {course.title}", - extra_tags=f"course_{course.slug}", - ) - - # Clear waiting room data from session - del request.session["waiting_room_data"] - - # Redirect back to waiting room to show the update - return redirect("waiting_room_detail", waiting_room_id=waiting_room.id) - - return redirect("course_detail", slug=course.slug) - else: - form = CourseForm() - - return render(request, "courses/create.html", {"form": form}) - - -@login_required -@teacher_required -def create_course_from_waiting_room(request, waiting_room_id): - waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) - - # Ensure waiting room is open - if waiting_room.status != "open": - messages.error(request, "This waiting room is no longer open.") - return redirect("waiting_room_detail", waiting_room_id=waiting_room_id) - - # Store waiting room data in session for validation - request.session["waiting_room_data"] = { - "id": waiting_room.id, - "subject": waiting_room.subject.strip().lower(), - "topics": [t.strip().lower() for t in waiting_room.topics.split(",") if t.strip()], - } - - # Redirect to regular course creation form - return redirect(reverse("create_course")) - - -@login_required -@teacher_required -def add_featured_review(request, slug, review_id): - # Get the course and review - course = get_object_or_404(Course, slug=slug) - review = get_object_or_404(Review, id=review_id, course=course) - - # Check if the user is the course teacher - if request.user != course.teacher: - messages.error(request, "Only the course teacher can manage featured reviews.") - return redirect(reverse("course_detail", kwargs={"slug": slug})) - - # Set the is_featured field to True - review.is_featured = True - review.save() - messages.success(request, "Review has been featured.") - - # Redirect to the course detail page - url = reverse("course_detail", kwargs={"slug": slug}) - return redirect(f"{url}#course_reviews") - - -@login_required -@teacher_required -def remove_featured_review(request, slug, review_id): - # Get the course and review - course = get_object_or_404(Course, slug=slug) - review = get_object_or_404(Review, id=review_id, course=course) - - # Check if the user is the course teacher - if request.user != course.teacher: - messages.error(request, "Only the course teacher can manage featured reviews.") - return redirect(reverse("course_detail", kwargs={"slug": slug})) - - # Set the is_featured field to False - review.is_featured = False - review.save() - - # Redirect to the course detail page - url = reverse("course_detail", kwargs={"slug": slug}) - return redirect(f"{url}#course_reviews") - - -@login_required -def edit_review(request, slug, review_id): - course = get_object_or_404(Course, slug=slug) - review = get_object_or_404(Review, id=review_id, course__slug=slug) - - # Security check - only allow editing own reviews - if request.user.id != review.student.id: - messages.error(request, "You can only edit your own reviews.") - return redirect("course_detail", slug=slug) - - if request.method == "POST": - form = ReviewForm(request.POST, instance=review) - if form.is_valid(): - review = form.save(commit=False) - review.save() - messages.success(request, "Your review has been updated.") - url = reverse("course_detail", kwargs={"slug": slug}) - return redirect(f"{url}#course_reviews") - else: - form = ReviewForm(instance=review) - - context = { - "form": form, - "course": course, - "review": review, - "action": "Edit", - } - return render(request, "courses/edit_or_add_review.html", context) - - -@login_required -def delete_review(request, slug, review_id): - review = get_object_or_404(Review, id=review_id, course__slug=slug) - - # Security check - only allow deleting own reviews - if request.user.id != review.student.id: - messages.error(request, "You can only delete your own reviews.") - else: - review.delete() - messages.success(request, "Your review has been deleted.") - - url = reverse("course_detail", kwargs={"slug": slug}) - return redirect(f"{url}#course_reviews") - - -def course_detail(request, slug): - course = get_object_or_404(Course, slug=slug) - sessions = course.sessions.all().order_by("start_time") - now = timezone.now() - is_teacher = request.user == course.teacher - completed_sessions = [] - # Check if user is the teacher of this course - - # Get enrollment if user is authenticated - enrollment = None - is_enrolled = False - if request.user.is_authenticated: - enrollment = Enrollment.objects.filter(course=course, student=request.user, status="approved").first() - is_enrolled = enrollment is not None - if enrollment: - # Get completed sessions through SessionAttendance - completed_sessions = SessionAttendance.objects.filter( - student=request.user, session__course=course, status="completed" - ).values_list("session__id", flat=True) - completed_sessions = course.sessions.filter(id__in=completed_sessions) - - # Get attendance data for all enrolled students - student_attendance = {} - total_sessions = sessions.count() - - if is_teacher or is_enrolled: - for enroll in course.enrollments.all(): - attended_sessions = SessionAttendance.objects.filter( - student=enroll.student, session__course=course, status__in=["present", "late"] - ).count() - student_attendance[enroll.student.id] = {"attended": attended_sessions, "total": total_sessions} - - # Mark past sessions as completed for display - past_sessions = sessions.filter(end_time__lt=now) - future_sessions = sessions.filter(end_time__gte=now) - sessions = list(future_sessions) + list(past_sessions) # Show future sessions first - - # Calendar data - today = timezone.now().date() - - # Get the requested month from query parameters, default to current month - try: - year = int(request.GET.get("year", today.year)) - month = int(request.GET.get("month", today.month)) - current_month = today.replace(year=year, month=month, day=1) - except (ValueError, TypeError): - current_month = today.replace(day=1) - - # Calculate previous and next month - if current_month.month == 1: - prev_month = current_month.replace(year=current_month.year - 1, month=12) - else: - prev_month = current_month.replace(month=current_month.month - 1) - - if current_month.month == 12: - next_month = current_month.replace(year=current_month.year + 1, month=1) - else: - next_month = current_month.replace(month=current_month.month + 1) - - # Get the calendar for current month - cal = calendar.monthcalendar(current_month.year, current_month.month) - - # Get all session dates for this course in current month - session_dates = set( - session.start_time.date() - for session in sessions - if session.start_time.year == current_month.year and session.start_time.month == current_month.month - ) - - # Prepare calendar weeks data - calendar_weeks = [] - for week in cal: - calendar_week = [] - for day in week: - if day == 0: - calendar_week.append({"date": None, "in_month": False, "has_session": False}) - else: - date = current_month.replace(day=day) - calendar_week.append({"date": date, "in_month": True, "has_session": date in session_dates}) - calendar_weeks.append(calendar_week) - - # Check if the current user has already reviewed this course - user_review = None - if request.user.is_authenticated: - user_review = Review.objects.filter(student=request.user, course=course).first() - - # Get all reviews That not featured for this course - reviews = course.reviews.filter(is_featured=False).order_by("-created_at") - - # Get the featured review - featured_review = Review.objects.filter(is_featured=True, course=course) - - # Get all reviews sum - reviews_num = reviews.count() + featured_review.count() - - # Calculate rating distribution for visualization - rating_counts = Review.objects.filter(course=course).values("rating").annotate(count=Count("id")) - rating_distribution = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0} - for item in rating_counts: - rating_distribution[item["rating"]] = item["count"] - - # Get next session for waiting room functionality - next_session = None - user_in_session_waiting_room = False - - if request.user.is_authenticated: - # Get the next upcoming session for this course - next_session = course.sessions.filter(start_time__gt=timezone.now()).order_by("start_time").first() - - # Check if user is in the session waiting room - try: - session_waiting_room = WaitingRoom.objects.get(course=course, status="open") - user_in_session_waiting_room = request.user in session_waiting_room.participants.all() - except WaitingRoom.DoesNotExist: - user_in_session_waiting_room = False - - # Build the absolute discount URL using the discount view's URL name. - from urllib.parse import urlencode - - discount_relative = reverse("apply_discount_via_referrer") - discount_params = urlencode({"course_id": course.id}) - discount_url = request.build_absolute_uri(f"{discount_relative}?{discount_params}") - - # Get active virtual classroom for the course - virtual_classroom = course.virtual_classrooms.filter(is_active=True).first() - - context = { - "course": course, - "sessions": sessions, - "now": now, - "today": today, - "is_teacher": is_teacher, - "is_enrolled": is_enrolled, - "enrollment": enrollment, - "completed_sessions": completed_sessions, - "calendar_weeks": calendar_weeks, - "current_month": current_month, - "prev_month": prev_month, - "next_month": next_month, - "student_attendance": student_attendance, - "completed_enrollment_count": course.enrollments.filter(status="completed").count(), - "in_progress_enrollment_count": course.enrollments.filter(status="in_progress").count(), - "featured_review": featured_review, - "reviews": reviews, - "user_review": user_review, - "rating_distribution": rating_distribution, - "reviews_num": reviews_num, - "discount_url": discount_url, - "virtual_classroom": virtual_classroom, - "next_session": next_session, - "user_in_session_waiting_room": user_in_session_waiting_room, - } - - return render(request, "courses/detail.html", context) - - -@login_required -def enroll_course(request, course_slug): - """Enroll in a course and handle referral rewards if applicable.""" - course = get_object_or_404(Course, slug=course_slug) - - # Check if user is already enrolled - if request.user.enrollments.filter(course=course).exists(): - messages.warning(request, "You are already enrolled in this course.") - return redirect("course_detail", slug=course_slug) - - # Check if course is full - if course.max_students and course.enrollments.count() >= course.max_students: - messages.error(request, "This course is full.") - return redirect("course_detail", slug=course_slug) - - # Check if this is the user's first enrollment and if they were referred - if not Enrollment.objects.filter(student=request.user).exists(): - if hasattr(request.user.profile, "referred_by") and request.user.profile.referred_by: - referrer = request.user.profile.referred_by - if not referrer.is_teacher: # Regular users get reward on first course enrollment - referrer.add_referral_earnings(5) - send_referral_reward_email(referrer.user, request.user, 5, "enrollment") - - # For free courses, create approved enrollment immediately - if course.price == 0: - enrollment = Enrollment.objects.create(student=request.user, course=course, status="approved") - # Send notifications for free courses - send_enrollment_confirmation(enrollment) - notify_teacher_new_enrollment(enrollment) - messages.success(request, "You have successfully enrolled in this free course.") - return redirect("course_detail", slug=course_slug) - else: - # For paid courses, create pending enrollment - enrollment = Enrollment.objects.create(student=request.user, course=course, status="pending") - messages.info(request, "Please complete the payment process to enroll in this course.") - return redirect("course_detail", slug=course_slug) - - -@login_required -def add_session(request, slug): - course = Course.objects.get(slug=slug) - if request.user != course.teacher: - messages.error(request, "Only the course teacher can add sessions!") - return redirect("course_detail", slug=slug) - - if request.method == "POST": - form = SessionForm(request.POST) - if form.is_valid(): - session = form.save(commit=False) - session.course = course - session.save() - # Send session notifications to enrolled students - notify_session_reminder(session) - messages.success(request, "Session added successfully!") - return redirect("course_detail", slug=slug) - else: - form = SessionForm() - - return render(request, "courses/session_form.html", {"form": form, "course": course, "is_edit": False}) - - -@login_required -def add_review(request, slug): - course = Course.objects.get(slug=slug) - student = request.user - - if not request.user.enrollments.filter(course=course).exists(): - messages.error(request, "Only enrolled students can review the course!") - return redirect("course_detail", slug=slug) - - if request.method == "POST": - form = ReviewForm(request.POST) - if form.is_valid(): - if Review.objects.filter(student=student, course=course).exists(): - messages.error(request, "You have already reviewed this course.") - return redirect("course_detail", slug=slug) - review = form.save(commit=False) - review.student = student - review.course = course - review.save() - messages.success(request, "Review added successfully!") - url = reverse("course_detail", kwargs={"slug": slug}) - return redirect(f"{url}#course_reviews") - else: - form = ReviewForm() - - return render(request, "courses/edit_or_add_review.html", {"form": form, "course": course, "action": "Add"}) - - -@login_required -def delete_course(request, slug): - """Handle course deletion, including image deletion.""" - course = get_object_or_404(Course, slug=slug) - - # Ensure only the course teacher can delete the course - if request.user != course.teacher: - messages.error(request, "You are not authorized to delete this course.") - return redirect("course_detail", slug=slug) - - if request.method == "POST": - - # Delete the course --> this automatically deletes the image too - course.delete() - messages.success(request, "Course deleted successfully!") - return redirect("profile") # Redirect to the profile page or another success page - - return render(request, "courses/delete_confirm.html", {"course": course}) - - -@csrf_exempt -def github_update(request): - """GitHub webhook endpoint to trigger a lightweight deploy. - - Hardening applied: - - Require POST. - - Validate X-Hub-Signature-256 using shared secret env var GITHUB_WEBHOOK_SECRET. - - Ignore unsupported events (only push by default). - - Run a safe pull + dependency install + migrate + collectstatic via a minimal bash snippet. - - Send concise status updates to Slack; avoid leaking secrets. - - NOTE: Full provisioning (packages, DB grants, etc.) still belongs to Ansible. - This endpoint just brings the already‑provisioned instance up to latest commit. - """ - if request.method != "POST": - return HttpResponseBadRequest("POST required") - - secret = getattr(settings, "GITHUB_WEBHOOK_SECRET", None) - signature = request.META.get("HTTP_X_HUB_SIGNATURE_256") - body = request.body - - if secret: - import hashlib - import hmac - - expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest() - if not signature or not hmac.compare_digest(expected, signature): - send_slack_message("GitHub webhook signature mismatch") - return HttpResponseForbidden("Invalid signature") - else: - # If no secret configured, refuse (better to explicitly set one) - return HttpResponseForbidden("Webhook secret not configured") - - event = request.META.get("HTTP_X_GITHUB_EVENT", "") - if event not in {"push"}: - return HttpResponse("Ignored event", status=202) - - repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - venv_python = os.path.join(repo_dir, "venv", "bin", "python") - venv_pip = os.path.join(repo_dir, "venv", "bin", "pip") - log_lines = [] - - # Resolve git binary explicitly since systemd service Environment may override PATH. - import shutil - - git_bin = shutil.which("git") or "/usr/bin/git" - if not os.path.exists(git_bin): - msg = f"Git binary not found at resolved path: {git_bin}. Aborting lightweight deploy." - send_slack_message(msg) - return HttpResponse(status=500, content=msg) - - def run_cmd(cmd): - proc = subprocess.run(cmd, cwd=repo_dir, capture_output=True, text=True) - summary = f"$ {' '.join(cmd)}\nrc={proc.returncode}\nstdout={proc.stdout[-400:]}\nstderr={proc.stderr[-400:]}" - log_lines.append(summary) - return proc.returncode == 0 - - poetry_bin = os.path.join(repo_dir, "venv", "bin", "poetry") - steps = [ - [git_bin, "fetch", "--all", "--prune"], - [git_bin, "reset", "--hard", "origin/main"], - [venv_pip, "install", "--upgrade", "pip", "wheel"], - [venv_pip, "install", "poetry==1.8.3"], - [poetry_bin, "config", "virtualenvs.create", "false", "--local"], - [poetry_bin, "install", "--only", "main", "--no-interaction", "--no-ansi"], - [venv_python, "manage.py", "migrate", "--noinput"], - [venv_python, "manage.py", "collectstatic", "--noinput"], - ] - - ok = True - for step in steps: - try: - if not run_cmd(step): - ok = False - break - except FileNotFoundError as fe: - # Capture explicit binary not found errors, send Slack, abort early. - err_msg = f"Webhook deploy step failed (missing binary): {fe}" - log_lines.append(err_msg) - send_slack_message(err_msg) - ok = False - break - - # Always attempt a reload so code changes take effect (application systemd unit) - subprocess.run(["/bin/systemctl", "restart", "education-website"], capture_output=True) - - # Slack summary (truncate to avoid long messages) - slack_msg = ( - ("Deploy success" if ok else "Deploy FAILED") - + " (github webhook)\n" - + "\n---\n" - + "\n---\n".join(line[:600] for line in log_lines[:4]) - ) - send_slack_message(slack_msg[:3500]) - - if ok: - return HttpResponse("OK") - return HttpResponse(status=500, content="Deploy failed; see logs") - - -def send_slack_message(message): - webhook_url = os.getenv("SLACK_WEBHOOK_URL") - if not webhook_url: - print("Warning: SLACK_WEBHOOK_URL not configured") - return - - payload = {"text": f"```{message}```"} - try: - response = requests.post(webhook_url, json=payload) - response.raise_for_status() # Raise exception for bad status codes - except Exception: - logger.error("Failed to send Slack message", exc_info=True) - - -def get_wsgi_last_modified_time(): - try: - return time.ctime(os.path.getmtime(settings.PA_WSGI)) - except Exception: - return "Unknown" - - -def subjects(request): - return render(request, "subjects.html") - - -def about(request): - return render(request, "about.html") - - -def waiting_rooms(request): - # Get open waiting rooms - open_rooms = WaitingRoom.objects.filter(status="open").order_by("-created_at") - - # Get fulfilled waiting rooms (ones that have associated courses) - fulfilled_rooms = WaitingRoom.objects.filter(status="fulfilled").order_by("-created_at") - - # Get rooms created by the user - user_created_rooms = ( - WaitingRoom.objects.filter(creator=request.user).order_by("-created_at") - if request.user.is_authenticated - else [] - ) - - # Get rooms the user has joined - user_joined_rooms = ( - WaitingRoom.objects.filter(participants=request.user).order_by("-created_at") - if request.user.is_authenticated - else [] - ) - - # Get topics for each room - room_topics = {} - all_rooms = list(open_rooms) + list(fulfilled_rooms) + list(user_created_rooms) + list(user_joined_rooms) - for room in all_rooms: - # Split topics string into a list - room_topics[room.id] = [t.strip() for t in room.topics.split(",")] if room.topics else [] - - context = { - "open_rooms": open_rooms, - "fulfilled_rooms": fulfilled_rooms, - "user_created_rooms": user_created_rooms, - "user_joined_rooms": user_joined_rooms, - "room_topics": room_topics, - } - - return render(request, "waiting_rooms.html", context) - - -def learn(request): - if request.method == "POST": - form = LearnForm(request.POST) - if form.is_valid(): - # Create waiting room - waiting_room = form.save(commit=False) - waiting_room.status = "open" # Set initial status - waiting_room.creator = request.user # Set the creator - - # Get topics from form and save as comma-separated string - topics = form.cleaned_data.get("topics", "") - if isinstance(topics, list): - topics = ", ".join(topics) - waiting_room.topics = topics - - waiting_room.save() - - # Redirect to waiting rooms page - return redirect("waiting_rooms") - - # Get form data - title = form.cleaned_data["title"] - description = form.cleaned_data["description"] - subject = form.cleaned_data["subject"] - topics = form.cleaned_data["topics"] - - # Prepare email content - email_subject = f"New Learning Request: {title}" - email_body = render_to_string( - "emails/learn_interest.html", - { - "title": title, - "description": description, - "subject": subject, - "topics": topics, - "user": request.user.username, - "waiting_room_id": waiting_room.id, - }, - ) - - # Send email - try: - send_mail( - email_subject, - email_body, - settings.DEFAULT_FROM_EMAIL, - [settings.DEFAULT_FROM_EMAIL], - html_message=email_body, - fail_silently=False, - ) - messages.success( - request, - "Thank you for your learning request!", - ) - return redirect("waiting_rooms") - except Exception: - logger = logging.getLogger(__name__) - logger.exception("Error sending email") - messages.error(request, "Sorry, there was an error sending your inquiry. Please try again later.") - else: - initial_data = {} - - # Handle query parameters - query = request.GET.get("query", "") - subject_param = request.GET.get("subject", "") - level = request.GET.get("level", "") - - # Try to match subject - if subject_param: - try: - subject = Subject.objects.get(name=subject_param) - initial_data["subject"] = subject.id - except Subject.DoesNotExist: - # Optionally, you could add the subject name to the description - initial_data["description"] = f"Looking for courses in {subject_param}" - - # If you want to include other parameters in the description - if query or level: - title_parts = [] - description_parts = [] - if query: - title_parts.append(f"{query}") - if level: - description_parts.append(f"Level: {level}") - - if "description" not in initial_data: - initial_data["title"] = " | ".join(title_parts) - else: - initial_data["description"] += " | " + " | ".join(description_parts) - - form = LearnForm(initial=initial_data) - return render(request, "learn.html", {"form": form}) - - -def teach(request): - """Handles the course creation process for both authenticated and unauthenticated users.""" - if request.method == "POST": - form = TeachForm(request.POST, request.FILES, user=request.user) - if form.is_valid(): - # Extract cleaned data - email = form.cleaned_data.get("email", None) - if email is None and request.user.is_authenticated: - email = request.user.email - course_title = form.cleaned_data["course_title"] - course_description = form.cleaned_data["course_description"] - course_image = form.cleaned_data.get("course_image") - preferred_session_times = form.cleaned_data["preferred_session_times"] - _ = form.cleaned_data.get("flexible_timing", False) - - # Determine the user for the course - user = None - is_new_user = False - - if request.user.is_authenticated: - # For authenticated users, always use the logged-in user - user = request.user - - # Backend validation: Check for duplicate course titles for the logged-in user - if Course.objects.filter(title__iexact=course_title, teacher=user).exists(): - form.add_error("course_title", "You already have a course with this title.") - return render(request, "teach.html", {"form": form}) - else: - # For unauthenticated users, check if the email exists or create a new user - try: - user = User.objects.get(email=email) - # User exists but isn't logged in; check if email is verified - email_address = EmailAddress.objects.filter(user=user, email=email, primary=True).first() - if email_address and email_address.verified: - messages.info( - request, - "An account with this email exists. Please login to finalize your course.", - ) - else: - # Email not verified, resend verification email - send_email_confirmation(request, user, signup=False) - messages.info( - request, - "An account with this email exists. Please verify your email to continue.", - ) - except User.DoesNotExist: - # Create a new user account - with transaction.atomic(): - # Generate a unique username - email_prefix = email.split("@")[0] - username = email_prefix - counter = 1 - while User.objects.filter(username=username).exists(): - username = f"{email_prefix}_{get_random_string(4)}_{counter}" - counter += 1 - - temp_password = get_random_string(length=8) - - # Create user with temporary password - user = User.objects.create_user(username=username, email=email, password=temp_password) - - # Update profile to be a teacher - profile, created = Profile.objects.get_or_create(user=user) - profile.is_teacher = True - profile.save() - - # Add email address for allauth verification - EmailAddress.objects.create(user=user, email=email, primary=True, verified=False) - - # Send verification email via allauth - send_email_confirmation(request, user, signup=True) - # Send welcome email with username, email, and temp password - try: - send_welcome_teach_course_email(request, user, temp_password) - except Exception: - messages.error(request, "Failed to send welcome email. Please try again.") - return render(request, "teach.html", {"form": form}) - - is_new_user = True - - # Backend validation: Check for duplicate course titles for unauthenticated users - if Course.objects.filter(title__iexact=course_title, teacher=user).exists(): - email_address = EmailAddress.objects.filter(user=user, email=email, primary=True).first() - if email_address and not email_address.verified: - # If the user is unverified, delete the existing draft and allow a new one - Course.objects.filter(title__iexact=course_title, teacher=user).delete() - else: - form.add_error("course_title", "You already have a course with this title.") - return render(request, "teach.html", {"form": form}) - - # Create a draft course - course = Course.objects.create( - title=course_title, - description=course_description, - teacher=user, - price=0, - max_students=12, - status="draft", - subject=Subject.objects.first() or Subject.objects.create(name="General"), - level="beginner", - ) - - # Handle course image if uploaded - if course_image: - course.image = course_image - course.save() - - # Create initial session if preferred time provided - if preferred_session_times: - Session.objects.create( - course=course, - title=f"{course_title} - Session 1", - description="First session of the course", - start_time=preferred_session_times, - end_time=preferred_session_times + timezone.timedelta(hours=1), - is_virtual=True, - ) - - # Handle redirection based on authentication status - if request.user.is_authenticated: - # If authenticated, mark as teacher and redirect to course setup - request.user.profile.is_teacher = True - request.user.profile.save() - messages.success( - request, f"Welcome! Your course '{course_title}' has been created. Please complete your setup." - ) - return redirect("course_detail", slug=course.slug) - else: - # Store course primary key in session for post-verification redirect - request.session["pending_course_id"] = course.pk - if is_new_user: - messages.success( - request, - "Your course has been created! " - "Please check your email for your username, password, and verification link to continue.", - ) - return redirect("account_email_verification_sent") - else: - # For existing users, redirect to login page - return redirect("account_login") - - else: - initial_data = {} - if request.GET.get("subject"): - initial_data["course_title"] = request.GET.get("subject") - form = TeachForm(initial=initial_data, user=request.user) - - return render(request, "teach.html", {"form": form}) - - -def send_welcome_teach_course_email(request, user, temp_password): - """Send welcome email with account and password setup instructions.""" - reset_url = request.build_absolute_uri(reverse("account_reset_password")) - - email_context = {"user": user, "reset_url": reset_url, "temp_password": temp_password} - - html_message = render_to_string("emails/welcome_teach_course.html", email_context) - text_message = render_to_string("emails/welcome_teach_course.txt", email_context) - - send_mail( - subject="Welcome to Your New Teaching Account.", - message=text_message, - html_message=html_message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - fail_silently=False, - ) - - -def course_search(request): - query = request.GET.get("q", "") - subject = request.GET.get("subject", "") - level = request.GET.get("level", "") - min_price = request.GET.get("min_price", "") - max_price = request.GET.get("max_price", "") - sort_by = request.GET.get("sort", "-created_at") - - courses = Course.objects.filter(status="published") - - # Apply filters - if query: - courses = courses.filter( - Q(title__icontains=query) - | Q(description__icontains=query) - | Q(tags__icontains=query) - | Q(learning_objectives__icontains=query) - | Q(prerequisites__icontains=query) - | Q(teacher__username__icontains=query) - | Q(teacher__first_name__icontains=query) - | Q(teacher__last_name__icontains=query) - | Q(teacher__profile__expertise__icontains=query) - ) - - if subject: - # Handle subject filtering based on whether it's an ID (number) or a string (slug/name) - try: - # Check if subject is an integer ID - subject_id = int(subject) - courses = courses.filter(subject_id=subject_id) - except ValueError: - # If not an integer, treat as a slug or name - courses = courses.filter(Q(subject__slug=subject) | Q(subject__name__iexact=subject)) - - if level: - courses = courses.filter(level=level) - - if min_price: - try: - min_price = float(min_price) - courses = courses.filter(price__gte=min_price) - except ValueError: - pass - - if max_price: - try: - max_price = float(max_price) - courses = courses.filter(price__lte=max_price) - except ValueError: - pass - - # Annotate with average rating for sorting - courses = courses.annotate( - avg_rating=Avg("reviews__rating"), - total_students=Count("enrollments", filter=Q(enrollments__status="approved")), - ) - - # Apply sorting - if sort_by == "price": - courses = courses.order_by("price", "-avg_rating") - elif sort_by == "-price": - courses = courses.order_by("-price", "-avg_rating") - elif sort_by == "title": - courses = courses.order_by("title") - elif sort_by == "rating": - courses = courses.order_by("-avg_rating", "-total_students") - else: # Default to newest - courses = courses.order_by("-created_at") - - # Get total count before pagination - total_results = courses.count() - - # Log the search (only if query is not blank or filters are applied) - if (query and query.strip()) or subject or level or min_price or max_price: - filters = { - "subject": subject, - "level": level, - "min_price": min_price, - "max_price": max_price, - "sort_by": sort_by, - } - SearchLog.objects.create( - query=query.strip() if query else "", - results_count=total_results, - user=request.user if request.user.is_authenticated else None, - filters_applied=filters, - search_type="course", - ) - - # Pagination - paginator = Paginator(courses, 12) # Show 12 courses per page - page_number = request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - is_teacher = getattr(getattr(request.user, "profile", None), "is_teacher", False) - - # initialize the user courses if founded - user_courses = set() - - # Get authenticated users courses - if request.user.is_authenticated: - if request.user.profile.is_teacher: - teacher_courses = list(Course.objects.filter(teacher=request.user)) - # Create a set of titles - user_courses = {course.title for course in teacher_courses} - else: - enrollments = Enrollment.objects.filter(student=request.user).select_related("course") - # Create a set of titles - user_courses = {course.course.title for course in enrollments} - - # Get dynamic subject choices from courses that are published - available_subjects = ( - Subject.objects.filter(courses__status="published") - .distinct() - .order_by("order", "name") - .values_list("slug", "name") - ) - - context = { - "page_obj": page_obj, - "query": query, - "subject": subject, - "level": level, - "min_price": min_price, - "max_price": max_price, - "sort_by": sort_by, - "subject_choices": list(available_subjects), - "level_choices": Course._meta.get_field("level").choices, - "total_results": total_results, - "is_teacher": is_teacher, - "user_courses": user_courses, - } - - return render(request, "courses/search.html", context) - - -@login_required -def create_payment_intent(request, slug): - """Create a payment intent for Stripe.""" - course = get_object_or_404(Course, slug=slug) - - # Prevent creating payment intents for free courses - if course.price == 0: - # Find the enrollment and update its status to approved if it's pending - enrollment = get_object_or_404(Enrollment, student=request.user, course=course) - if enrollment.status == "pending": - enrollment.status = "approved" - enrollment.save() - - # Send notifications - send_enrollment_confirmation(enrollment) - notify_teacher_new_enrollment(enrollment) - - return JsonResponse({"free_course": True, "message": "Enrollment approved for free course"}) - - # Ensure user has a pending enrollment - enrollment = get_object_or_404(Enrollment, student=request.user, course=course, status="pending") - - # Validate price is greater than zero for Stripe - if course.price <= 0: - enrollment.status = "approved" - enrollment.save() - - # Send notifications - send_enrollment_confirmation(enrollment) - notify_teacher_new_enrollment(enrollment) - - return JsonResponse({"free_course": True, "message": "Enrollment approved for free course"}) - - try: - # Create a PaymentIntent with the order amount and currency - intent = stripe.PaymentIntent.create( - amount=int(course.price * 100), # Convert to cents - currency="usd", - metadata={ - "course_id": course.id, - "user_id": request.user.id, - }, - ) - return JsonResponse({"clientSecret": intent.client_secret}) - except stripe.error.StripeError: - logger.error("Stripe error occurred", exc_info=True) - return JsonResponse({"error": "Payment processing error. Please try again."}, status=400) - except Exception: - logger.error("Unexpected error in payment intent creation", exc_info=True) - return JsonResponse({"error": "An internal error occurred. Please try again."}, status=500) - - -@csrf_exempt -def stripe_webhook(request): - """Stripe webhook endpoint for handling payment events.""" - payload = request.body - sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") - - try: - event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_WEBHOOK_SECRET) - except ValueError: - # Invalid payload - return HttpResponse(status=400) - except stripe.error.SignatureVerificationError: - # Invalid signature - return HttpResponse(status=400) - - if event.type == "payment_intent.succeeded": - payment_intent = event.data.object - handle_successful_payment(payment_intent) - elif event.type == "payment_intent.payment_failed": - payment_intent = event.data.object - handle_failed_payment(payment_intent) - - return HttpResponse(status=200) - - -def handle_successful_payment(payment_intent): - """Handle successful payment by enrolling the user in the course.""" - # Get metadata from the payment intent - course_id = payment_intent.metadata.get("course_id") - user_id = payment_intent.metadata.get("user_id") - - # Create enrollment and payment records - course = Course.objects.get(id=course_id) - user = User.objects.get(id=user_id) - - # Create enrollment with pending status - enrollment = Enrollment.objects.get_or_create(student=user, course=course, defaults={"status": "pending"})[0] - - # Update status to approved after successful payment - enrollment.status = "approved" - enrollment.save() - - # Create a payment record for tracking teacher earnings - # Convert amount from cents to dollars - amount = Decimal(str(payment_intent.amount)) / 100 - Payment.objects.create( - enrollment=enrollment, - amount=amount, - currency=payment_intent.currency.upper(), - stripe_payment_intent_id=payment_intent.id, - status="completed", - ) - - # Send notifications - send_enrollment_confirmation(enrollment) - notify_teacher_new_enrollment(enrollment) - - -def handle_failed_payment(payment_intent): - """Handle failed payment.""" - course_id = payment_intent.metadata.get("course_id") - user_id = payment_intent.metadata.get("user_id") - - try: - course = Course.objects.get(id=course_id) - user = User.objects.get(id=user_id) - enrollment = Enrollment.objects.get(student=user, course=course) - enrollment.status = "pending" - enrollment.save() - except (Course.DoesNotExist, User.DoesNotExist, Enrollment.DoesNotExist): - pass # Log error or handle appropriately - - -@login_required -def update_course(request, slug): - course = get_object_or_404(Course, slug=slug) - if request.user != course.teacher: - return HttpResponseForbidden() - - if request.method == "POST": - form = CourseForm(request.POST, request.FILES, instance=course) - if form.is_valid(): - form.save() - messages.success(request, "Course updated successfully!") - return redirect("course_detail", slug=course.slug) - else: - form = CourseForm(instance=course) - - return render(request, "courses/update.html", {"form": form, "course": course}) - - -@login_required -def mark_session_attendance(request, session_id): - session = Session.objects.get(id=session_id) - if request.user != session.course.teacher: - messages.error(request, "Only the course teacher can mark attendance!") - return redirect("course_detail", slug=session.course.slug) - - if request.method == "POST": - for student_id, status in request.POST.items(): - if student_id.startswith("student_"): - student_id = student_id.replace("student_", "") - student = User.objects.get(id=student_id) - attendance, created = SessionAttendance.objects.update_or_create( - session=session, student=student, defaults={"status": status} - ) - messages.success(request, "Attendance marked successfully!") - return redirect("course_detail", slug=session.course.slug) - - enrollments = session.course.enrollments.filter(status="approved") - attendances = {att.student_id: att.status for att in session.attendances.all()} - - context = { - "session": session, - "enrollments": enrollments, - "attendances": attendances, - } - return render(request, "courses/mark_attendance.html", context) - - -@login_required -def mark_session_completed(request, session_id): - session = Session.objects.get(id=session_id) - enrollment = request.user.enrollments.get(course=session.course) - - if enrollment.status != "approved": - messages.error(request, "You must be enrolled in the course to mark sessions as completed!") - return redirect("course_detail", slug=session.course.slug) - - progress, created = CourseProgress.objects.get_or_create(enrollment=enrollment) - progress.completed_sessions.add(session) - - # Check for achievements - if progress.completion_percentage == 100: - Achievement.objects.get_or_create( - student=request.user, - course=session.course, - achievement_type="completion", - defaults={ - "title": "Course Completed!", - "description": f"Completed all sessions in {session.course.title}", - }, - ) - - if progress.attendance_rate == 100: - Achievement.objects.get_or_create( - student=request.user, - course=session.course, - achievement_type="attendance", - defaults={ - "title": "Perfect Attendance!", - "description": f"Attended all sessions in {session.course.title}", - }, - ) - - messages.success(request, "Session marked as completed!") - return redirect("course_detail", slug=session.course.slug) - - -@login_required -def award_achievement(request): - try: - profile = request.user.profile - if not profile.is_teacher: - messages.error(request, "You do not have permission to award achievements.") - return redirect("teacher_dashboard") - except Profile.DoesNotExist: - messages.error(request, "Profile not found.") - return redirect("teacher_dashboard") - - if request.method == "POST": - form = AwardAchievementForm(request.POST, teacher=request.user) - if form.is_valid(): - Achievement.objects.create( - student=form.cleaned_data["student"], - course=form.cleaned_data["course"], - achievement_type=form.cleaned_data["achievement_type"], - title=form.cleaned_data["title"], - description=form.cleaned_data["description"], - badge_icon=form.cleaned_data["badge_icon"], - ) - messages.success( - request, - f'Achievement "{form.cleaned_data["title"]}" awarded to {form.cleaned_data["student"].username}.', - ) - return redirect("teacher_dashboard") - else: - # Show an error message if the form is invalid - messages.error(request, "There was an error in the form submission. Please check the form and try again.") - else: - form = AwardAchievementForm(teacher=request.user) - return render(request, "award_achievement.html", {"form": form}) - - -@login_required -def student_progress(request, enrollment_id): - enrollment = Enrollment.objects.get(id=enrollment_id) - - if request.user != enrollment.student and request.user != enrollment.course.teacher: - messages.error(request, "You don't have permission to view this progress!") - return redirect("course_detail", slug=enrollment.course.slug) - - progress, created = CourseProgress.objects.get_or_create(enrollment=enrollment) - achievements = Achievement.objects.filter(student=enrollment.student, course=enrollment.course) - - past_sessions = enrollment.course.sessions.filter(start_time__lt=timezone.now()) - upcoming_sessions = enrollment.course.sessions.filter(start_time__gte=timezone.now()) - - context = { - "enrollment": enrollment, - "progress": progress, - "achievements": achievements, - "past_sessions": past_sessions, - "upcoming_sessions": upcoming_sessions, - "stripe_public_key": ( - settings.STRIPE_PUBLISHABLE_KEY if enrollment.status == "pending" and enrollment.course.price > 0 else None - ), - } - return render(request, "courses/student_progress.html", context) - - -@login_required -def course_progress_overview(request, slug): - course = Course.objects.get(slug=slug) - if request.user != course.teacher: - messages.error(request, "Only the course teacher can view the progress overview!") - return redirect("course_detail", slug=slug) - - enrollments = course.enrollments.filter(status="approved") - progress_data = [] - - for enrollment in enrollments: - progress, created = CourseProgress.objects.get_or_create(enrollment=enrollment) - attendance_data = ( - SessionAttendance.objects.filter(student=enrollment.student, session__course=course) - .values("status") - .annotate(count=models.Count("status")) - ) - - progress_data.append( - { - "enrollment": enrollment, - "progress": progress, - "attendance": attendance_data, - } - ) - - context = { - "course": course, - "progress_data": progress_data, - } - return render(request, "courses/progress_overview.html", context) - - -@login_required -def upload_material(request, slug): - course = get_object_or_404(Course, slug=slug) - if request.user != course.teacher: - return HttpResponseForbidden("You are not authorized to upload materials for this course.") - - if request.method == "POST": - form = CourseMaterialForm(request.POST, request.FILES, course=course) - if form.is_valid(): - material = form.save(commit=False) - material.course = course - material.save() - messages.success(request, "Course material uploaded successfully!") - return redirect("course_detail", slug=course.slug) - else: - form = CourseMaterialForm(course=course) - - return render(request, "courses/upload_material.html", {"form": form, "course": course}) - - -@login_required -def delete_material(request, slug, material_id): - material = get_object_or_404(CourseMaterial, id=material_id, course__slug=slug) - if request.user != material.course.teacher: - return HttpResponseForbidden("You are not authorized to delete this material.") - - if request.method == "POST": - material.delete() - messages.success(request, "Course material deleted successfully!") - return redirect("course_detail", slug=slug) - - return render(request, "courses/delete_material_confirm.html", {"material": material}) - - -@login_required -def download_material(request, slug, material_id): - material = get_object_or_404(CourseMaterial, id=material_id, course__slug=slug) - if not material.is_downloadable and request.user != material.course.teacher: - return HttpResponseForbidden("This material is not available for download.") - - try: - return FileResponse(material.file, as_attachment=True) - except FileNotFoundError: - messages.error(request, "The requested file could not be found.") - return redirect("course_detail", slug=slug) - - -@login_required -@teacher_required -def course_marketing(request, slug): - """View for managing course marketing and promotions.""" - course = get_object_or_404(Course, slug=slug, teacher=request.user) - - if request.method == "POST": - action = request.POST.get("action") - - if action == "send_promotional_emails": - send_course_promotion_email( - course=course, - subject=f"New Course Recommendation: {course.title}", - template_name="course_promotion", - ) - messages.success(request, "Promotional emails have been sent successfully.") - - elif action == "generate_social_content": - social_content = generate_social_share_content(course) - return JsonResponse({"social_content": social_content}) - - # Get analytics and recommendations - analytics = get_course_analytics(course) - recommendations = get_promotion_recommendations(course) - - context = { - "course": course, - "analytics": analytics, - "recommendations": recommendations, - } - - return render(request, "courses/marketing.html", context) - - -@login_required -@teacher_required -def course_analytics(request, slug): - """View for displaying detailed course analytics.""" - course = get_object_or_404(Course, slug=slug, teacher=request.user) - analytics = get_course_analytics(course) - - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({"analytics": analytics}) - - context = { - "course": course, - "analytics": analytics, - } - - return render(request, "courses/analytics.html", context) - - -@login_required -def calendar_feed(request): - """Generate and serve an iCal feed of the user's course sessions.""" - - response = HttpResponse(generate_ical_feed(request.user), content_type="text/calendar") - response["Content-Disposition"] = f'attachment; filename="{settings.SITE_NAME}-schedule.ics"' - return response - - -@login_required -def calendar_links(request, session_id): - """Get calendar links for a specific session.""" - - session = get_object_or_404(Session, id=session_id) - - # Check if user has access to this session - if not ( - request.user == session.course.teacher - or request.user.enrollments.filter(course=session.course, status="approved").exists() - ): - return HttpResponseForbidden("You don't have access to this session.") - - links = { - "google": generate_google_calendar_link(session), - "outlook": generate_outlook_calendar_link(session), - } - - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({"links": links}) - - return render( - request, - "courses/calendar_links.html", - { - "session": session, - "calendar_links": links, - }, - ) - - -def forum_categories(request): - """Display all forum categories.""" - categories = ForumCategory.objects.all() - return render(request, "web/forum/categories.html", {"categories": categories}) - - -def forum_category(request, slug): - """Display topics in a specific category.""" - category = get_object_or_404(ForumCategory, slug=slug) - topics = category.topics.all() - categories = ForumCategory.objects.all() - return render( - request, "web/forum/category.html", {"category": category, "topics": topics, "categories": categories} - ) - - -def forum_topic(request, category_slug, topic_id): - """Display a forum topic and its replies.""" - topic = get_object_or_404(ForumTopic, id=topic_id, category__slug=category_slug) - categories = ForumCategory.objects.all() - - # Get view count from WebRequest model - view_count = ( - WebRequest.objects.filter(path=request.path).aggregate(total_views=models.Sum("count"))["total_views"] or 0 - ) - topic.views = view_count - topic.save() - - # Handle POST requests for replies, voting, and deletion - if request.method == "POST": - action = request.POST.get("action") - - if action == "add_reply" and request.user.is_authenticated: - content = request.POST.get("content") - if content: - ForumReply.objects.create(topic=topic, author=request.user, content=content) - messages.success(request, "Reply added successfully.") - return redirect("forum_topic", category_slug=category_slug, topic_id=topic_id) - - elif action == "delete_reply" and request.user.is_authenticated: - reply_id = request.POST.get("reply_id") - reply = get_object_or_404(ForumReply, id=reply_id, author=request.user) - reply.delete() - messages.success(request, "Reply deleted successfully.") - return redirect("forum_topic", category_slug=category_slug, topic_id=topic_id) - - elif action == "delete_topic" and request.user == topic.author: - topic.delete() - messages.success(request, "Topic deleted successfully.") - return redirect("forum_category", slug=category_slug) - - # Fetch replies after POST handling - replies = topic.replies.select_related("author").order_by("created_at") - - # Votes handling - if request.user.is_authenticated: - user_topic_vote = topic.user_vote(request.user) - user_reply_votes = {reply.id: reply.user_vote(request.user) for reply in replies} - else: - user_topic_vote = None - user_reply_votes = {} - - return render( - request, - "web/forum/topic.html", - { - "topic": topic, - "replies": replies, - "categories": categories, - "user_topic_vote": user_topic_vote, - "user_reply_votes": user_reply_votes, - }, - ) - - -@login_required -def create_topic(request, category_slug): - """Create a new forum topic.""" - category = get_object_or_404(ForumCategory, slug=category_slug) - categories = ForumCategory.objects.all() - - if request.method == "POST": - form = ForumTopicForm(request.POST) - if form.is_valid(): - topic = ForumTopic.objects.create( - category=category, - author=request.user, - title=form.cleaned_data["title"], - content=form.cleaned_data["content"], - github_issue_url=form.cleaned_data.get("github_issue_url", ""), - github_milestone_url=form.cleaned_data.get("github_milestone_url", ""), - ) - messages.success(request, "Topic created successfully!") - return redirect("forum_topic", category_slug=category_slug, topic_id=topic.id) - else: - form = ForumTopicForm() - - return render( - request, "web/forum/create_topic.html", {"category": category, "form": form, "categories": categories} - ) - - -@login_required -def peer_connections(request): - """Display user's peer connections.""" - sent_connections = request.user.sent_connections.all() - received_connections = request.user.received_connections.all() - return render( - request, - "web/peer/connections.html", - { - "sent_connections": sent_connections, - "received_connections": received_connections, - }, - ) - - -@login_required -def send_connection_request(request, user_id): - """Send a peer connection request.""" - receiver = get_object_or_404(User, id=user_id) - - if request.user == receiver: - messages.error(request, "You cannot connect with yourself!") - return redirect("peer_connections") - - connection, created = PeerConnection.objects.get_or_create( - sender=request.user, receiver=receiver, defaults={"status": "pending"} - ) - - if created: - messages.success(request, f"Connection request sent to {receiver.username}!") - else: - messages.info(request, f"Connection request already sent to {receiver.username}.") - - return redirect("peer_connections") - - -@login_required -def handle_connection_request(request, connection_id, action): - """Accept or reject a peer connection request.""" - connection = get_object_or_404(PeerConnection, id=connection_id, receiver=request.user, status="pending") - - if action == "accept": - connection.status = "accepted" - messages.success(request, f"Connection with {connection.sender.username} accepted!") - elif action == "reject": - connection.status = "rejected" - messages.info(request, f"Connection with {connection.sender.username} rejected.") - - connection.save() - return redirect("peer_connections") - - -@login_required -def peer_messages(request, user_id): - """Display and handle messages with a peer.""" - peer = get_object_or_404(User, id=user_id) - - # Check if users are connected - connection = PeerConnection.objects.filter( - (Q(sender=request.user, receiver=peer) | Q(sender=peer, receiver=request.user)), - status="accepted", - ).first() - - if not connection: - messages.error(request, "You must be connected with this user to send messages.") - return redirect("peer_connections") - - if request.method == "POST": - content = request.POST.get("content") - if content: - PeerMessage.objects.create(sender=request.user, receiver=peer, content=content) - messages.success(request, "Message sent!") - - # Get conversation messages - messages_list = PeerMessage.objects.filter( - (Q(sender=request.user, receiver=peer) | Q(sender=peer, receiver=request.user)) - ).order_by("created_at") - - # Mark received messages as read - messages_list.filter(sender=peer, receiver=request.user, is_read=False).update(is_read=True) - - return render(request, "web/peer/messages.html", {"peer": peer, "messages": messages_list}) - - -@login_required -def study_groups(request, course_id): - """Display study groups for a course.""" - course = get_object_or_404(Course, id=course_id) - groups = course.study_groups.all() - - if request.method == "POST": - name = request.POST.get("name") - description = request.POST.get("description") - max_members = request.POST.get("max_members", 10) - is_private = request.POST.get("is_private", False) - - if name and description: - group = StudyGroup.objects.create( - course=course, - creator=request.user, - name=name, - description=description, - max_members=max_members, - is_private=is_private, - ) - group.members.add(request.user) - messages.success(request, "Study group created successfully!") - return redirect("study_group_detail", group_id=group.id) - - return render(request, "web/study/groups.html", {"course": course, "groups": groups}) - - -@login_required -def study_group_detail(request, group_id): - """Display study group details and handle join/leave requests.""" - group = get_object_or_404(StudyGroup, id=group_id) - - if request.method == "POST": - action = request.POST.get("action") - - if action == "join": - if group.members.count() >= group.max_members: - messages.error(request, "This group is full!") - else: - group.members.add(request.user) - messages.success(request, f"You have joined {group.name}!") - - elif action == "leave": - if request.user == group.creator: - messages.error(request, "Group creator cannot leave the group!") - else: - group.members.remove(request.user) - messages.info(request, f"You have left {group.name}.") - - return render(request, "web/study/group_detail.html", {"group": group}) - - -# API Views -@login_required -def api_course_list(request): - """API endpoint for listing courses.""" - courses = Course.objects.filter(status="published") - data = [ - { - "id": course.id, - "title": course.title, - "description": course.description, - "teacher": course.teacher.username, - "price": str(course.price), - "subject": course.subject, - "level": course.level, - "slug": course.slug, - } - for course in courses - ] - return JsonResponse(data, safe=False) - - -@login_required -@teacher_required -def api_course_create(request): - """API endpoint for creating a course.""" - if request.method != "POST": - return JsonResponse({"error": "Only POST method is allowed"}, status=405) - - data = json.loads(request.body) - course = Course.objects.create( - teacher=request.user, - title=data["title"], - description=data["description"], - learning_objectives=data["learning_objectives"], - prerequisites=data.get("prerequisites", ""), - price=data["price"], - max_students=data["max_students"], - subject=data["subject"], - level=data["level"], - ) - return JsonResponse( - { - "id": course.id, - "title": course.title, - "slug": course.slug, - }, - status=201, - ) - - -@login_required -def api_course_detail(request, slug): - """API endpoint for course details.""" - course = get_object_or_404(Course, slug=slug) - data = { - "id": course.id, - "title": course.title, - "description": course.description, - "teacher": course.teacher.username, - "price": str(course.price), - "subject": course.subject, - "level": course.level, - "prerequisites": course.prerequisites, - "learning_objectives": course.learning_objectives, - "max_students": course.max_students, - "available_spots": course.available_spots, - "average_rating": course.average_rating, - } - return JsonResponse(data) - - -@login_required -def api_enroll(request, course_slug): - """API endpoint for course enrollment.""" - if request.method != "POST": - return JsonResponse({"error": "Only POST method is allowed"}, status=405) - - course = get_object_or_404(Course, slug=course_slug) - if request.user.enrollments.filter(course=course).exists(): - return JsonResponse({"error": "Already enrolled"}, status=400) - - enrollment = Enrollment.objects.create( - student=request.user, - course=course, - status="pending", - ) - return JsonResponse( - { - "id": enrollment.id, - "status": enrollment.status, - }, - status=201, - ) - - -@login_required -def api_enrollments(request): - """API endpoint for listing user enrollments.""" - enrollments = request.user.enrollments.all() - data = [ - { - "id": enrollment.id, - "course": { - "id": enrollment.course.id, - "title": enrollment.course.title, - "slug": enrollment.course.slug, - }, - "status": enrollment.status, - "enrollment_date": enrollment.enrollment_date.isoformat(), - } - for enrollment in enrollments - ] - return JsonResponse(data, safe=False) - - -@login_required -def api_session_list(request, course_slug): - """API endpoint for listing course sessions.""" - course = get_object_or_404(Course, slug=course_slug) - sessions = course.sessions.all() - data = [ - { - "id": session.id, - "title": session.title, - "description": session.description, - "start_time": session.start_time.isoformat(), - "end_time": session.end_time.isoformat(), - "is_virtual": session.is_virtual, - } - for session in sessions - ] - return JsonResponse(data, safe=False) - - -@login_required -def api_session_detail(request, pk): - """API endpoint for session details.""" - session = get_object_or_404(Session, pk=pk) - data = { - "id": session.id, - "title": session.title, - "description": session.description, - "start_time": session.start_time.isoformat(), - "end_time": session.end_time.isoformat(), - "is_virtual": session.is_virtual, - "meeting_link": session.meeting_link if session.is_virtual else None, - "location": session.location if not session.is_virtual else None, - } - return JsonResponse(data) - - -@login_required -def api_forum_topic_create(request): - """API endpoint for creating forum topics.""" - if request.method != "POST": - return JsonResponse({"error": "Only POST method is allowed"}, status=405) - - data = json.loads(request.body) - category = get_object_or_404(ForumCategory, id=data["category"]) - topic = ForumTopic.objects.create( - title=data["title"], - content=data["content"], - category=category, - author=request.user, - ) - return JsonResponse( - { - "id": topic.id, - "title": topic.title, - }, - status=201, - ) - - -@login_required -def api_forum_reply_create(request): - """API endpoint for creating forum replies.""" - if request.method != "POST": - return JsonResponse({"error": "Only POST method is allowed"}, status=405) - - data = json.loads(request.body) - topic = get_object_or_404(ForumTopic, id=data["topic"]) - reply = ForumReply.objects.create( - topic=topic, - content=data["content"], - author=request.user, - ) - return JsonResponse( - { - "id": reply.id, - "content": reply.content, - }, - status=201, - ) - - -@login_required -def session_detail(request, session_id): - try: - session = get_object_or_404(Session, id=session_id) - - # Check access rights - if not ( - request.user == session.course.teacher - or request.user.enrollments.filter(course=session.course, status="approved").exists() - ): - return HttpResponseForbidden("You don't have access to this session") - - # Get next session for waiting room functionality - next_session = None - user_in_session_waiting_room = False - - if request.user.is_authenticated: - # Get the next upcoming session for this course - next_session = session.course.sessions.filter(start_time__gt=timezone.now()).order_by("start_time").first() - - # Check if user is in the session waiting room - try: - session_waiting_room = WaitingRoom.objects.get(course=session.course, status="open") - user_in_session_waiting_room = request.user in session_waiting_room.participants.all() - except WaitingRoom.DoesNotExist: - user_in_session_waiting_room = False - - context = { - "session": session, - "is_teacher": request.user == session.course.teacher, - "now": timezone.now(), - "next_session": next_session, - "user_in_session_waiting_room": user_in_session_waiting_room, - } - - return render(request, "web/study/session_detail.html", context) - - except Session.DoesNotExist: - messages.error(request, "Session not found") - return redirect("course_search") - except Exception as e: - if settings.DEBUG: - raise e - messages.error(request, "An error occurred while loading the session") - return redirect("index") - - -def blog_list(request): - blog_posts = BlogPost.objects.filter(status="published").order_by("-published_at") - tags = BlogPost.objects.values_list("tags", flat=True).distinct() - # Split comma-separated tags and get unique values - unique_tags = sorted(set(tag.strip() for tags_str in tags if tags_str for tag in tags_str.split(","))) - - return render(request, "blog/list.html", {"blog_posts": blog_posts, "tags": unique_tags}) - - -def blog_tag(request, tag): - """View for filtering blog posts by tag.""" - blog_posts = BlogPost.objects.filter(status="published", tags__icontains=tag).order_by("-published_at") - tags = BlogPost.objects.values_list("tags", flat=True).distinct() - # Split comma-separated tags and get unique values - unique_tags = sorted(set(tag.strip() for tags_str in tags if tags_str for tag in tags_str.split(","))) - - return render(request, "blog/list.html", {"blog_posts": blog_posts, "tags": unique_tags, "current_tag": tag}) - - -@login_required -def create_blog_post(request): - if request.method == "POST": - form = BlogPostForm(request.POST, request.FILES) - if form.is_valid(): - post = form.save(commit=False) - post.author = request.user - post.save() - messages.success(request, "Blog post created successfully!") - return redirect("blog_detail", slug=post.slug) - else: - form = BlogPostForm() - - return render(request, "blog/create.html", {"form": form}) - - -def blog_detail(request, slug): - """Display a blog post and its comments.""" - post = get_object_or_404(BlogPost, slug=slug, status="published") - comments = post.comments.filter(is_approved=True).order_by("created_at") - - if request.method == "POST": - if not request.user.is_authenticated: - messages.error(request, "Please log in to comment.") - return redirect("account_login") - - comment_content = request.POST.get("content") - if comment_content: - comment = BlogComment.objects.create( - post=post, author=request.user, content=comment_content, is_approved=True # Auto-approve for now - ) - messages.success(request, f"Comment #{comment.id} added successfully!") - return redirect("blog_detail", slug=slug) - - # Get view count from WebRequest - view_count = WebRequest.objects.filter(path=request.path).aggregate(total_views=Sum("count"))["total_views"] or 0 - - context = { - "post": post, - "comments": comments, - "view_count": view_count, - } - return render(request, "blog/detail.html", context) - - -@login_required -def student_dashboard(request): - """ - Dashboard view for students showing enrollments, progress, upcoming sessions, learning streak, - and an Achievements section. - """ - - # Update the learning streak. - streak, created = LearningStreak.objects.get_or_create(user=request.user) - streak.update_streak() - - enrollments = Enrollment.objects.filter(student=request.user).select_related("course") - upcoming_sessions = Session.objects.filter( - course__enrollments__student=request.user, start_time__gt=timezone.now() - ).order_by("start_time")[:5] - - progress_data = [] - total_progress = 0 - for enrollment in enrollments: - progress, _ = CourseProgress.objects.get_or_create(enrollment=enrollment) - progress_data.append( - { - "enrollment": enrollment, - "progress": progress, - } - ) - total_progress += progress.completion_percentage - - avg_progress = round(total_progress / len(progress_data)) if progress_data else 0 - - # Query achievements for the user. - achievements = Achievement.objects.filter(student=request.user).order_by("-awarded_at") - - context = { - "enrollments": enrollments, - "upcoming_sessions": upcoming_sessions, - "progress_data": progress_data, - "avg_progress": avg_progress, - "streak": streak, - "achievements": achievements, - } - return render(request, "dashboard/student.html", context) - - -@login_required -@teacher_required -def teacher_dashboard(request): - """Dashboard view for teachers showing their courses, student progress, and upcoming sessions. - - The earnings calculation is based on completed payment records, not just enrollments. - This ensures that earnings accurately reflect actual transactions rather than just the - number of enrolled students. Each payment has a 90% teacher commission rate applied. - """ - courses = Course.objects.filter(teacher=request.user) - upcoming_sessions = Session.objects.filter(course__teacher=request.user, start_time__gt=timezone.now()).order_by( - "start_time" - )[:5] - - # Get enrollment and progress stats for each course - course_stats = [] - total_students = 0 - total_completed = 0 - total_earnings = Decimal("0.00") - for course in courses: - enrollments = course.enrollments.filter(status="approved") - course_total_students = enrollments.count() - course_completed = enrollments.filter(status="completed").count() - total_students += course_total_students - total_completed += course_completed - - # Calculate earnings based on completed payments instead of enrollment count - # Each payment has an amount field which represents the actual amount paid - # We apply the teacher's commission rate (90% by default, 10% platform fee) - course_earnings = Decimal("0.00") - for enrollment in enrollments: - # Get all completed payments for this enrollment - completed_payments = enrollment.payments.filter(status="completed") - for payment in completed_payments: - # Apply the 90% teacher commission - course_earnings += payment.amount * Decimal("0.9") - - total_earnings += course_earnings - course_stats.append( - { - "course": course, - "total_students": course_total_students, - "completed": course_completed, - "completion_rate": (course_completed / course_total_students * 100) if course_total_students > 0 else 0, - "earnings": course_earnings, - } - ) - - # Get the teacher's storefront if it exists - storefront = Storefront.objects.filter(teacher=request.user).first() - - context = { - "courses": courses, - "upcoming_sessions": upcoming_sessions, - "course_stats": course_stats, - "total_students": total_students, - "completion_rate": (total_completed / total_students * 100) if total_students > 0 else 0, - "total_earnings": round(total_earnings, 2), - "storefront": storefront, - } - return render(request, "dashboard/teacher.html", context) - - -def custom_404(request, exception): - """Custom 404 error handler""" - return render(request, "404.html", status=404) - - -# def custom_500(request): -# """Custom 500 error handler""" -# return render(request, "500.html", status=500) - - -def custom_429(request, exception=None): - """Custom 429 error page.""" - return render(request, "429.html", status=429) - - -def cart_view(request): - """View the shopping cart.""" - cart = get_or_create_cart(request) - return render(request, "cart/cart.html", {"cart": cart, "stripe_public_key": settings.STRIPE_PUBLISHABLE_KEY}) - - -def add_course_to_cart(request, course_id): - """Add a course to the cart.""" - course = get_object_or_404(Course, id=course_id) - cart = get_or_create_cart(request) - - # Try to get or create the cart item - cart_item, created = CartItem.objects.get_or_create(cart=cart, course=course, defaults={"session": None}) - - if created: - messages.success(request, f"{course.title} added to cart.") - else: - messages.info(request, f"{course.title} is already in your cart.") - - return redirect("cart_view") - - -def add_session_to_cart(request, session_id): - """Add an individual session to the cart.""" - session = get_object_or_404(Session, id=session_id) - cart = get_or_create_cart(request) - - # Try to get or create the cart item - cart_item, created = CartItem.objects.get_or_create(cart=cart, session=session, defaults={"course": None}) - - if created: - messages.success(request, f"{session.title} added to cart.") - else: - messages.info(request, f"{session.title} is already in your cart.") - - return redirect("cart_view") - - -def remove_from_cart(request, item_id): - """Remove an item from the shopping cart.""" - cart = get_or_create_cart(request) - item = get_object_or_404(CartItem, id=item_id, cart=cart) - item.delete() - messages.success(request, "Item removed from cart.") - return redirect("cart_view") - - -def create_cart_payment_intent(request): - """Create a payment intent for the entire cart.""" - cart = get_or_create_cart(request) - - if not cart.items.exists(): - return JsonResponse({"error": "Cart is empty"}, status=400) - - # Handle free cart (all items are free courses) - if cart.total == 0: - return JsonResponse({"free_cart": True, "message": "Cart contains only free items"}) - - try: - # Create a PaymentIntent with the cart total - intent = stripe.PaymentIntent.create( - amount=int(cart.total * 100), # Convert to cents - currency="usd", - metadata={ - "cart_id": cart.id, - "user_id": request.user.id if request.user.is_authenticated else None, - "session_key": request.session.session_key if not request.user.is_authenticated else None, - }, - ) - return JsonResponse({"clientSecret": intent.client_secret}) - except Exception as e: - return JsonResponse({"error": str(e)}, status=403) - - -@login_required -def free_cart_checkout(request): - """Handle checkout for cart with only free items.""" - if request.method != "POST": - messages.error(request, "Invalid request method.") - return redirect("cart_view") - - cart = get_or_create_cart(request) - - if not cart.items.exists(): - messages.error(request, "Cart is empty.") - return redirect("cart_view") - - # Verify that cart total is 0 (all items are free) - if cart.total != 0: - messages.error(request, "Cart contains paid items. Please use regular checkout.") - return redirect("cart_view") - - user = request.user - enrollments = [] - session_enrollments = [] - goods_items = [] - - # Create the Order - order = Order.objects.create( - user=user, - total_price=0, - status="completed", - shipping_address=None, - terms_accepted=True, - ) - - # Process enrollments - for item in cart.items.all(): - if item.course: - # Create enrollment for free course - enrollment = Enrollment.objects.create(student=user, course=item.course, status="approved") - enrollments.append(enrollment) - - # Send notifications - send_enrollment_confirmation(enrollment) - notify_teacher_new_enrollment(enrollment) - - elif item.session: - # Process individual session enrollments - session_enrollment = SessionEnrollment.objects.create(student=user, session=item.session, status="approved") - session_enrollments.append(session_enrollment) - - elif item.goods: - # Free goods (price = 0) - goods_items.append(item) - OrderItem.objects.create( - order=order, - goods=item.goods, - quantity=1, - price_at_purchase=0, - discounted_price_at_purchase=0, - ) - - # Clear the cart - cart.items.all().delete() - - # Render the receipt page - return render( - request, - "cart/receipt.html", - { - "payment_intent_id": None, - "order_date": timezone.now(), - "user": user, - "enrollments": enrollments, - "session_enrollments": session_enrollments, - "goods_items": goods_items, - "total": 0, - "order": order, - "shipping_address": None, - }, - ) - - -def checkout_success(request): - """Handle successful checkout and payment confirmation.""" - payment_intent_id = request.GET.get("payment_intent") - - if not payment_intent_id: - messages.error(request, "No payment information found.") - return redirect("cart_view") - - try: - # Verify the payment intent - payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id) - - if payment_intent.status != "succeeded": - messages.error(request, "Payment was not successful.") - return redirect("cart_view") - - cart = get_or_create_cart(request) - - if not cart.items.exists(): - messages.error(request, "Cart is empty.") - return redirect("cart_view") - - # Handle guest checkout - if not request.user.is_authenticated: - email = payment_intent.receipt_email - if not email: - messages.error(request, "No email provided for guest checkout.") - return redirect("cart_view") - - # Create a new user account with transaction and better username generation - with transaction.atomic(): - # Generate a random username without using the email - timestamp = timezone.now().strftime("%Y%m%d%H%M%S") - username = f"user_{timestamp}" - - # In the unlikely case of a collision, append random string - while User.objects.filter(username=username).exists(): - username = f"user_{timestamp}_{get_random_string(6)}" - - # Create the user - user = User.objects.create_user( - username=username, - email=email, - password=get_random_string(length=32), # Random password for reset - ) - - # Associate the cart with the new user - cart.user = user - cart.session_key = "" # Empty string instead of None - cart.save() - - # Send welcome email with password reset link - send_welcome_email(user) - - # Log in the new user - login(request, user, backend="django.contrib.auth.backends.ModelBackend") - else: - user = request.user - - # Lists to track enrollments for the receipt - enrollments = [] - session_enrollments = [] - goods_items = [] - total_amount = 0 - - # Define shipping_address - shipping_address = request.POST.get("address") if cart.has_goods else None - - # Check if the cart contains goods requiring shipping - has_goods = any(item.goods for item in cart.items.all()) - - # Extract shipping address from Stripe PaymentIntent - shipping_address = None - if has_goods: - shipping_data = getattr(payment_intent, "shipping", None) - if shipping_data: - # Construct structured shipping address - shipping_address = { - "line1": shipping_data.address.line1, - "line2": shipping_data.address.line2 or "", - "city": shipping_data.address.city, - "state": shipping_data.address.state, - "postal_code": shipping_data.address.postal_code, - "country": shipping_data.address.country, - } - - # Create the Order with shipping address - order = Order.objects.create( - user=user, # User is defined earlier in guest/auth logic - total_price=0, # Updated later - status="completed", - shipping_address=shipping_address, - terms_accepted=True, - ) - - storefront = None - # Process enrollments - for item in cart.items.all(): - if item.course: - # Check for an active discount coupon for this course - discount = Discount.objects.filter( - user=user, course=item.course, used=False, valid_until__gte=timezone.now() - ).first() - if discount: - # Calculate the discounted price - discount_amount = (discount.discount_percentage / 100) * item.course.price - effective_price = item.course.price - discount_amount - # Mark the coupon as used - discount.used = True - discount.save() - else: - effective_price = item.course.price - - # Create enrollment for the course - enrollment = Enrollment.objects.create( - student=user, course=item.course, status="approved", payment_intent_id=payment_intent_id - ) - enrollments.append(enrollment) - total_amount += effective_price - - # Create payment record for teacher earnings calculation - Payment.objects.create( - enrollment=enrollment, - amount=effective_price, - currency="USD", - stripe_payment_intent_id=payment_intent_id, - status="completed", - ) - - # Optionally, you can send confirmation emails with discount details - send_enrollment_confirmation(enrollment) - notify_teacher_new_enrollment(enrollment) - - elif item.session: - # Process individual session enrollments (no discount logic here) - session_enrollment = SessionEnrollment.objects.create( - student=user, session=item.session, status="approved", payment_intent_id=payment_intent_id - ) - session_enrollments.append(session_enrollment) - total_amount += item.session.price - - elif item.goods: - goods_items.append(item) - total_amount += item.final_price - OrderItem.objects.create( - order=order, - goods=item.goods, - quantity=1, - price_at_purchase=item.goods.price, - discounted_price_at_purchase=item.goods.discount_price, - ) - if not storefront: - storefront = item.goods.storefront - - # Update order details - order.total_price = total_amount - if storefront: - order.storefront = goods_items[0].goods.storefront - order.save() - - # Clear the cart - cart.items.all().delete() - - if storefront: - order.storefront = storefront - order.save(update_fields=["storefront"]) - - # Render the receipt page - return render( - request, - "cart/receipt.html", - { - "payment_intent_id": payment_intent_id, - "order_date": timezone.now(), - "user": user, - "enrollments": enrollments, - "session_enrollments": session_enrollments, - "goods_items": goods_items, - "total": total_amount, - "order": order, - "shipping_address": shipping_address, - }, - ) - - except stripe.error.StripeError as e: - # send slack message - send_slack_message(f"Payment verification failed: {str(e)}") - messages.error(request, f"Payment verification failed: {str(e)}") - return redirect("cart_view") - except Exception as e: - # send slack message - send_slack_message(f"Failed to process checkout: {str(e)}") - messages.error(request, f"Failed to process checkout: {str(e)}") - return redirect("cart_view") - - -def send_welcome_email(user): - """Send welcome email to newly created users after guest checkout.""" - if not user.email: - raise ValueError("User must have an email address to send welcome email") - - reset_url = reverse("account_reset_password") - context = { - "user": user, - "reset_url": reset_url, - } - - html_message = render_to_string("emails/welcome_guest.html", context) - text_message = render_to_string("emails/welcome_guest.txt", context) - - send_mail( - subject="Welcome to Your New Learning Account", - message=text_message, - html_message=html_message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[user.email], - ) - - -@login_required -def edit_session(request, session_id): - """Edit an existing session.""" - # Get the session and verify that the current user is the course teacher - session = get_object_or_404(Session, id=session_id) - course = session.course - - # Check if user is the course teacher - if request.user != course.teacher: - messages.error(request, "Only the course teacher can edit sessions!") - return redirect("course_detail", slug=course.slug) - - if request.method == "POST": - form = SessionForm(request.POST, instance=session) - if form.is_valid(): - form.save() - messages.success(request, "Session updated successfully!") - return redirect("course_detail", slug=session.course.slug) - else: - form = SessionForm(instance=session) - - return render( - request, "courses/session_form.html", {"form": form, "session": session, "course": course, "is_edit": True} - ) - - -@login_required -def invite_student(request, course_id): - course = get_object_or_404(Course, id=course_id) - - # Check if user is the teacher of this course - if course.teacher != request.user: - messages.error(request, "You are not authorized to invite students to this course.") - return redirect("course_detail", slug=course.slug) - - if request.method == "POST": - form = InviteStudentForm(request.POST) - if form.is_valid(): - email = form.cleaned_data["email"] - message = form.cleaned_data.get("message", "") - - # Generate course URL - course_url = request.build_absolute_uri(reverse("course_detail", args=[course.slug])) - - # Send invitation email - context = { - "course": course, - "teacher": request.user, - "message": message, - "course_url": course_url, - } - html_message = render_to_string("emails/course_invitation.html", context) - text_message = f""" -You have been invited to join {course.title}! - -Message from {request.user.get_full_name() or request.user.username}: -{message} - -Course Price: ${course.price} - -Click here to view the course: {course_url} -""" - - try: - send_mail( - f"Invitation to join {course.title}", - text_message, - settings.DEFAULT_FROM_EMAIL, - [email], - html_message=html_message, - ) - messages.success(request, f"Invitation sent to {email}") - return redirect("course_detail", slug=course.slug) - except Exception: - messages.error(request, "Failed to send invitation email. Please try again.") - else: - form = InviteStudentForm() - - context = { - "course": course, - "form": form, - } - return render(request, "courses/invite.html", context) - - -def terms(request): - """Display the terms of service page.""" - return render(request, "terms.html") - - -@login_required -@teacher_required -def stripe_connect_onboarding(request): - """Start the Stripe Connect onboarding process for teachers.""" - if not request.user.profile.is_teacher: - messages.error(request, "Only teachers can set up payment accounts.") - return redirect("profile") - - try: - if not request.user.profile.stripe_account_id: - # Create a new Stripe Connect account - account = stripe.Account.create( - type="express", - country="US", - email=request.user.email, - capabilities={ - "card_payments": {"requested": True}, - "transfers": {"requested": True}, - }, - ) - - # Save the account ID to the user's profile - request.user.profile.stripe_account_id = account.id - request.user.profile.save() - - # Create an account link for onboarding - account_link = stripe.AccountLink.create( - account=request.user.profile.stripe_account_id, - refresh_url=request.build_absolute_uri(reverse("stripe_connect_onboarding")), - return_url=request.build_absolute_uri(reverse("profile")), - type="account_onboarding", - ) - - return redirect(account_link.url) - - except stripe.error.StripeError as e: - messages.error(request, f"Failed to set up Stripe account: {str(e)}") - return redirect("profile") - - -@csrf_exempt -def stripe_connect_webhook(request): - """Handle Stripe Connect account updates.""" - payload = request.body - sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") - - try: - event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_CONNECT_WEBHOOK_SECRET) - except ValueError: - return HttpResponse(status=400) - except stripe.error.SignatureVerificationError: - return HttpResponse(status=400) - - if event.type == "account.updated": - account = event.data.object - try: - profile = Profile.objects.get(stripe_account_id=account.id) - if account.charges_enabled and account.payouts_enabled: - profile.stripe_account_status = "verified" - else: - profile.stripe_account_status = "pending" - profile.save() - except Profile.DoesNotExist: - return HttpResponse(status=404) - - return HttpResponse(status=200) - - -@login_required -def create_forum_category(request): - """Create a new forum category.""" - if request.method == "POST": - form = ForumCategoryForm(request.POST) - if form.is_valid(): - category = form.save(commit=False) - if not category.slug: - category.slug = slugify(category.name) - category.save() - messages.success(request, f"Forum category '{category.name}' created successfully!") - return redirect("forum_category", slug=category.slug) - else: - print(form.errors) - else: - form = ForumCategoryForm() - - return render(request, "web/forum/create_category.html", {"form": form}) - - -@login_required -def edit_topic(request, topic_id): - topic = get_object_or_404(ForumTopic, id=topic_id, author=request.user) - categories = ForumCategory.objects.all() - - if request.method == "POST": - form = ForumTopicForm(request.POST) - if form.is_valid(): - # Manually update the topic instance with form data. - topic.title = form.cleaned_data["title"] - topic.content = form.cleaned_data["content"] - topic.github_issue_url = form.cleaned_data.get("github_issue_url", "") - topic.github_milestone_url = form.cleaned_data.get("github_milestone_url", "") - topic.save() - messages.success(request, "Topic updated successfully!") - return redirect("forum_topic", category_slug=topic.category.slug, topic_id=topic.id) - else: - # Prepopulate the form with the topic's current data. - initial_data = { - "title": topic.title, - "content": topic.content, - "github_issue_url": topic.github_issue_url, - "github_milestone_url": topic.github_milestone_url, - } - form = ForumTopicForm(initial=initial_data) - - return render(request, "web/forum/edit_topic.html", {"topic": topic, "form": form, "categories": categories}) - - -@login_required -def my_forum_topics(request): - """Display all forum topics created by the current user.""" - topics = ForumTopic.objects.filter(author=request.user).order_by("-created_at") - categories = ForumCategory.objects.all() - return render(request, "web/forum/my_topics.html", {"topics": topics, "categories": categories}) - - -@login_required -def my_forum_replies(request): - """Display all forum replies created by the current user.""" - replies = ( - ForumReply.objects.filter(author=request.user) - .select_related("topic", "topic__category") - .order_by("-created_at") - ) - categories = ForumCategory.objects.all() - return render(request, "web/forum/my_replies.html", {"replies": replies, "categories": categories}) - - -@login_required -def edit_reply(request, reply_id): - """Edit a forum reply.""" - reply = get_object_or_404(ForumReply, id=reply_id, author=request.user) - topic = reply.topic - categories = ForumCategory.objects.all() - - if request.method == "POST": - content = request.POST.get("content") - if content: - reply.content = content - reply.save() - messages.success(request, "Reply updated successfully.") - return redirect("forum_topic", category_slug=topic.category.slug, topic_id=topic.id) - - return render(request, "web/forum/edit_reply.html", {"reply": reply, "categories": categories}) - - -def get_course_calendar(request, slug): - """AJAX endpoint to get calendar data for a course.""" - course = get_object_or_404(Course, slug=slug) - today = timezone.now().date() - calendar_weeks = [] - - # Get current month and year from query parameters - year = int(request.GET.get("year", today.year)) - month = int(request.GET.get("month", today.month)) - current_month = timezone.datetime(year, month, 1).date() - - # Get previous and next month for navigation - if month == 1: - prev_month = {"year": year - 1, "month": 12} - else: - prev_month = {"year": year, "month": month - 1} - - if month == 12: - next_month = {"year": year + 1, "month": 1} - else: - next_month = {"year": year, "month": month + 1} - - # Get sessions for the current month - month_sessions = course.sessions.filter(start_time__year=year, start_time__month=month).order_by("start_time") - - # Generate calendar data - cal = calendar.monthcalendar(year, month) - - for week in cal: - calendar_week = [] - for day in week: - if day == 0: - calendar_week.append({"date": None, "has_session": False, "is_today": False}) - else: - date = timezone.datetime(year, month, day).date() - sessions_on_day = [s for s in month_sessions if s.start_time.date() == date] - calendar_week.append( - { - "date": date.isoformat() if date else None, - "has_session": bool(sessions_on_day), - "is_today": date == today, - } - ) - calendar_weeks.append(calendar_week) - - data = { - "calendar_weeks": calendar_weeks, - "current_month": current_month.strftime("%B %Y"), - "prev_month": prev_month, - "next_month": next_month, - } - - return JsonResponse(data) - - -@login_required -def create_calendar(request): - if request.method == "POST": - title = request.POST.get("title") - description = request.POST.get("description") - try: - month = int(request.POST.get("month")) - year = int(request.POST.get("year")) - - # Validate month is between 0-11 - if not 0 <= month <= 11: - return JsonResponse({"success": False, "error": "Month must be between 0 and 11"}, status=400) - - calendar = EventCalendar.objects.create( - title=title, description=description, creator=request.user, month=month, year=year - ) - - return JsonResponse({"success": True, "calendar_id": calendar.id, "share_token": calendar.share_token}) - except (ValueError, TypeError): - return JsonResponse({"success": False, "error": "Invalid month or year"}, status=400) - - return render(request, "calendar/create.html") - - -def view_calendar(request, share_token): - calendar = get_object_or_404(EventCalendar, share_token=share_token) - return render(request, "calendar/view.html", {"calendar": calendar}) - - -@require_POST -def add_time_slot(request, share_token): - try: - with transaction.atomic(): - calendar = get_object_or_404(EventCalendar, share_token=share_token) - name = request.POST.get("name") - day = int(request.POST.get("day")) - start_time = request.POST.get("start_time") - end_time = request.POST.get("end_time") - - # Create the time slot - TimeSlot.objects.create(calendar=calendar, name=name, day=day, start_time=start_time, end_time=end_time) - - return JsonResponse({"success": True}) - except IntegrityError: - return JsonResponse({"success": False, "error": "You already have a time slot for this day"}, status=400) - except Exception as e: - return JsonResponse({"success": False, "error": str(e)}, status=400) - - -@require_POST -def remove_time_slot(request, share_token): - calendar = get_object_or_404(EventCalendar, share_token=share_token) - name = request.POST.get("name") - day = int(request.POST.get("day")) - - TimeSlot.objects.filter(calendar=calendar, name=name, day=day).delete() - - return JsonResponse({"success": True}) - - -@require_GET -def get_calendar_data(request, share_token): - calendar = get_object_or_404(EventCalendar, share_token=share_token) - slots = TimeSlot.objects.filter(calendar=calendar) - - data = { - "title": calendar.title, - "description": calendar.description, - "month": calendar.month, - "year": calendar.year, - "slots": [ - { - "name": slot.name, - "day": slot.day, - "start_time": slot.start_time.strftime("%H:%M"), - "end_time": slot.end_time.strftime("%H:%M"), - } - for slot in slots - ], - } - - return JsonResponse(data) - - -def system_status(request): - """Check system status including SendGrid API connectivity and disk space usage.""" - status = { - "sendgrid": {"status": "unknown", "message": "", "api_key_configured": False}, - "disk_space": {"status": "unknown", "message": "", "usage": {}}, - "timestamp": timezone.now(), - } - - # Check SendGrid - sendgrid_api_key = os.getenv("SENDGRID_PASSWORD") - if sendgrid_api_key: - status["sendgrid"]["api_key_configured"] = True - try: - print("Checking SendGrid API...") - response = requests.get( - "https://api.sendgrid.com/v3/user/account", - headers={"Authorization": f"Bearer {sendgrid_api_key}"}, - timeout=5, - ) - if response.status_code == 200: - status["sendgrid"]["status"] = "ok" - status["sendgrid"]["message"] = "Successfully connected to SendGrid API" - else: - status["sendgrid"]["status"] = "error" - status["sendgrid"]["message"] = f"Unexpected response: {response.status_code}" - except requests.exceptions.RequestException as e: - status["sendgrid"]["status"] = "error" - status["sendgrid"]["message"] = f"API Error: {str(e)}" - else: - status["sendgrid"]["status"] = "error" - status["sendgrid"]["message"] = "SendGrid API key not configured" - - # Check disk space - try: - total, used, free = shutil.disk_usage("/") - total_gb = total / (2**30) # Convert to GB - used_gb = used / (2**30) - free_gb = free / (2**30) - usage_percent = (used / total) * 100 - - status["disk_space"]["usage"] = { - "total_gb": round(total_gb, 2), - "used_gb": round(used_gb, 2), - "free_gb": round(free_gb, 2), - "percent": round(usage_percent, 1), - } - - # Set status based on usage percentage - if usage_percent >= 90: - status["disk_space"]["status"] = "error" - status["disk_space"]["message"] = "Critical: Disk usage above 90%" - elif usage_percent >= 80: - status["disk_space"]["status"] = "warning" - status["disk_space"]["message"] = "Warning: Disk usage above 80%" - else: - status["disk_space"]["status"] = "ok" - status["disk_space"]["message"] = "Disk space usage is normal" - except Exception as e: - status["disk_space"]["status"] = "error" - status["disk_space"]["message"] = f"Error checking disk space: {str(e)}" - - return render(request, "status.html", {"status": status}) - - -@login_required -@teacher_required -def message_enrolled_students(request, slug): - """Send an email to all enrolled students in a course with encrypted content.""" - course = get_object_or_404(Course, slug=slug, teacher=request.user) - - if request.method == "POST": - title = request.POST.get("title") - message = request.POST.get("message") - - if title and message: - original_message = message - - # Get all enrolled students - enrolled_students = User.objects.filter( - enrollments__course=course, enrollments__status="approved" - ).distinct() - - # Send email to each student with the encrypted message - for student in enrolled_students: - send_mail( - subject=f"[{course.title}] {title}", - message=original_message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[student.email], - fail_silently=True, - ) - - messages.success(request, "Email sent successfully to all enrolled students!") - return redirect("course_detail", slug=slug) - else: - messages.error(request, "Both title and message are required!") - - return render(request, "courses/message_students.html", {"course": course}) - - -def message_teacher(request, teacher_id): - """Send a message to a teacher with secure encryption.""" - teacher = get_object_or_404(get_user_model(), id=teacher_id) - if not teacher.profile.is_teacher: - messages.error(request, "This user is not a teacher.") - return redirect("index") - - if request.method == "POST": - form = MessageTeacherForm(request.POST, user=request.user) - if form.is_valid(): - original_message = request.POST.get("message") - - # Prepare email content - if request.user.is_authenticated: - sender_name = request.user.get_full_name() or request.user.username - sender_email = request.user.email - else: - sender_name = form.cleaned_data["name"] - sender_email = form.cleaned_data["email"] - - # Send email to teacher using the encrypted message - context = { - "sender_name": sender_name, - "sender_email": sender_email, - "message": original_message, - "inbox_url": request.build_absolute_uri(reverse("inbox")), - "messaging_dashboard_url": request.build_absolute_uri(reverse("messaging_dashboard")), - } - html_message = render_to_string("web/emails/teacher_message.html", context) - - try: - send_mail( - subject=f"New message from {sender_name}", - message=original_message, - from_email=settings.DEFAULT_FROM_EMAIL, - recipient_list=[teacher.email], - html_message=html_message, - ) - messages.success(request, "Your message has been sent successfully!") - - # Optionally redirect based on next URL parameter - next_url = request.GET.get("next") - if next_url: - try: - return redirect("course_detail", slug=next_url) - except NoReverseMatch: - pass - return redirect("course_search") - except Exception as e: - messages.error(request, f"Failed to send message: {str(e)}") - return redirect("message_teacher", teacher_id=teacher_id) - else: - form = MessageTeacherForm(user=request.user) - - return render( - request, - "web/message_teacher.html", - { - "form": form, - "teacher": teacher, - }, - ) - - -@login_required -def confirm_rolled_sessions(request, course_slug): - """View for teachers to confirm rolled over session dates.""" - course = get_object_or_404(Course, slug=course_slug, teacher=request.user) - - # Get all rolled over but unconfirmed sessions - rolled_sessions = course.sessions.filter(is_rolled_over=True, teacher_confirmed=False).order_by("start_time") - - if request.method == "POST": - session_ids = request.POST.getlist("confirm_sessions") - if session_ids: - # Confirm selected sessions - course.sessions.filter(id__in=session_ids).update(teacher_confirmed=True) - messages.success(request, "Selected sessions have been confirmed.") - - # Reset rollover status for unselected sessions - unselected_sessions = rolled_sessions.exclude(id__in=session_ids) - for session in unselected_sessions: - session.start_time = session.original_start_time - session.end_time = session.original_end_time - session.is_rolled_over = False - session.save() - - messages.info(request, "Unselected sessions have been reset to their original dates.") - - return redirect("course_detail", slug=course_slug) - - return render( - request, - "courses/confirm_rolled_sessions.html", - { - "course": course, - "rolled_sessions": rolled_sessions, - }, - ) - - -def feedback(request): - if request.method == "POST": - form = FeedbackForm(request.POST) - if form.is_valid(): - # Send feedback notification to admin - name = form.cleaned_data.get("name", "Anonymous") - email = form.cleaned_data.get("email", "Not provided") - description = form.cleaned_data["description"] - - # Send to Slack if webhook URL is configured - if settings.SLACK_WEBHOOK_URL: - message = f"*New Feedback*\nFrom: {name}\nEmail: {email}\n\n{description}" - send_slack_message(message) - - messages.success(request, "Thank you for your feedback! We appreciate your input.") - return redirect("feedback") - else: - form = FeedbackForm() - - return render(request, "feedback.html", {"form": form}) - - -def content_dashboard(request): - # Get current time and thresholds - now = timezone.now() - month_ago = now - timedelta(days=30) - - def get_status(date, threshold_days=None): - if not date: - return "neutral" - if not threshold_days: - return "success" - threshold = now - timedelta(days=threshold_days) - if date >= threshold: - return "success" - elif date >= (threshold - timedelta(days=threshold_days)): - return "warning" - return "danger" - - # Web traffic stats - web_stats = { - "total_views": WebRequest.objects.aggregate(total=Sum("count"))["total"] or 0, - "unique_visitors": WebRequest.objects.values("ip_address").distinct().count(), - "date": WebRequest.objects.order_by("-created").first().created if WebRequest.objects.exists() else None, - } - web_stats["status"] = get_status(web_stats["date"]) - - # Generate traffic data for chart (last 30 days) - traffic_data = [] - for i in range(30): - date = now - timedelta(days=i) - day_views = WebRequest.objects.filter(created__date=date.date()).aggregate(total=Sum("count"))["total"] or 0 - traffic_data.append({"date": date.strftime("%Y-%m-%d"), "views": day_views}) - traffic_data.reverse() # Most recent last for chart - - # Blog stats - blog_stats = { - "posts": BlogPost.objects.filter(status="published").count(), - "views": (WebRequest.objects.filter(path__startswith="/blog/").aggregate(total=Sum("count"))["total"] or 0), - "date": ( - BlogPost.objects.filter(status="published").order_by("-published_at").first().published_at - if BlogPost.objects.exists() - else None - ), - } - blog_stats["status"] = get_status(blog_stats["date"], 7) - - # Forum stats - forum_stats = { - "topics": ForumTopic.objects.count(), - "replies": ForumReply.objects.count(), - "date": ForumTopic.objects.order_by("-created_at").first().created_at if ForumTopic.objects.exists() else None, - } - forum_stats["status"] = get_status(forum_stats["date"], 1) # 1 day threshold - - # Course stats - course_stats = { - "active": Course.objects.filter(status="published").count(), - "students": Enrollment.objects.filter(status="approved").count(), - "date": Course.objects.order_by("-created_at").first().created_at if Course.objects.exists() else None, - } - course_stats["status"] = get_status(course_stats["date"], 30) # 1 month threshold - - # User stats - user_stats = { - "total": User.objects.count(), - "active": User.objects.filter(last_login__gte=month_ago).count(), - "date": User.objects.order_by("-date_joined").first().date_joined if User.objects.exists() else None, - } - - def get_status(date, threshold_days): - if not date: - return "danger" - days_since = (now - date).days - if days_since > threshold_days * 2: - return "danger" - elif days_since > threshold_days: - return "warning" - return "success" - - # Calculate overall health score - connected_platforms = 0 - healthy_platforms = 0 - platforms_data = [ - (blog_stats["date"], 7), # Blog: 1 week threshold - (forum_stats["date"], 7), # Forum: 1 week threshold - (course_stats["date"], 7), # Courses: 1 week threshold - (user_stats["date"], 7), # Users: 1 week threshold - ] - - for date, threshold in platforms_data: - if date: - connected_platforms += 1 - if get_status(date, threshold) != "danger": - healthy_platforms += 1 - - overall_score = int((healthy_platforms / max(connected_platforms, 1)) * 100) - - # Get social media stats - social_stats = get_social_stats() - content_data = { - "blog": { - "stats": blog_stats, - "status": get_status(blog_stats["date"], 7), - "date": blog_stats["date"], - }, - "forum": { - "stats": forum_stats, - "status": get_status(forum_stats["date"], 7), - "date": forum_stats["date"], - }, - "courses": { - "stats": course_stats, - "status": get_status(course_stats["date"], 7), - "date": course_stats["date"], - }, - "users": { - "stats": user_stats, - "status": get_status(user_stats["date"], 7), - "date": user_stats["date"], - }, - } - - # Add social media stats - content_data.update(social_stats) - - return render( - request, - "web/dashboard/content_status.html", - { - "content_data": content_data, - "overall_score": overall_score, - "web_stats": web_stats, - "traffic_data": json.dumps(traffic_data), - "blog_stats": blog_stats, - "forum_stats": forum_stats, - "course_stats": course_stats, - "user_stats": user_stats, - }, - ) - - -# Challenges views -def current_weekly_challenge(request): - current_time = timezone.now() - weekly_challenge = Challenge.objects.filter( - challenge_type="weekly", start_date__lte=current_time, end_date__gte=current_time - ).first() - - one_time_challenges = Challenge.objects.filter( - challenge_type="one_time", start_date__lte=current_time, end_date__gte=current_time - ) - user_submissions = {} - if request.user.is_authenticated: - # Get all active challenges - all_challenges = [] - if weekly_challenge: - all_challenges.append(weekly_challenge) - all_challenges.extend(list(one_time_challenges)) - - # Get all submissions for active challenges - if all_challenges: - submissions = ChallengeSubmission.objects.filter(user=request.user, challenge__in=all_challenges) - # Create a dictionary mapping challenge IDs to submissions - for submission in submissions: - user_submissions[submission.challenge_id] = submission - - return render( - request, - "web/current_weekly_challenge.html", - { - "current_challenge": weekly_challenge, - "one_time_challenges": one_time_challenges, - "user_submissions": user_submissions if request.user.is_authenticated else {}, - }, - ) - - -def challenge_detail(request, challenge_id): - try: - challenge = get_object_or_404(Challenge, id=challenge_id) - submissions = ChallengeSubmission.objects.filter(challenge=challenge) - # Check if the current user has submitted this challenge - user_submission = None - if request.user.is_authenticated: - user_submission = ChallengeSubmission.objects.filter(user=request.user, challenge=challenge).first() - - return render( - request, - "web/challenge_detail.html", - {"challenge": challenge, "submissions": submissions, "user_submission": user_submission}, - ) - except Http404: - # Redirect to weekly challenges list if specific challenge not found - messages.info(request, "Challenge not found. Returning to challenges list.") - return redirect("current_weekly_challenge") - - -@login_required -def challenge_submit(request, challenge_id): - challenge = get_object_or_404(Challenge, id=challenge_id) - # Check if the user has already submitted this challenge - existing_submission = ChallengeSubmission.objects.filter(user=request.user, challenge=challenge).first() - - if existing_submission: - return redirect("challenge_detail", challenge_id=challenge_id) - - if request.method == "POST": - form = ChallengeSubmissionForm(request.POST) - if form.is_valid(): - submission = form.save(commit=False) - submission.user = request.user - submission.challenge = challenge - submission.save() - messages.success(request, "Your submission has been recorded!") - return redirect("challenge_detail", challenge_id=challenge_id) - else: - form = ChallengeSubmissionForm() - - return render(request, "web/challenge_submit.html", {"challenge": challenge, "form": form}) - - -@require_GET -def fetch_video_title(request): - """ - Fetch video title from a URL with proper security measures to prevent SSRF attacks. - """ - url = request.GET.get("url") - if not url: - return JsonResponse({"error": "URL parameter is required"}, status=400) - - # Validate URL - try: - parsed_url = urlparse(url) - - # Check for scheme - only allow http and https - if parsed_url.scheme not in ["http", "https"]: - return JsonResponse({"error": "Invalid URL scheme. Only HTTP and HTTPS are supported."}, status=400) - - # Check for private/internal IP addresses - if parsed_url.netloc: - hostname = parsed_url.netloc.split(":")[0] - - # Block localhost variations and common internal domains - blocked_hosts = [ - "localhost", - "127.0.0.1", - "0.0.0.0", - "internal", - "intranet", - "local", - "lan", - "corp", - "private", - "::1", - ] - - if any(blocked in hostname.lower() for blocked in blocked_hosts): - return JsonResponse({"error": "Access to internal networks is not allowed"}, status=403) - - # Resolve hostname to IP and check if it's private - try: - ip_address = socket.gethostbyname(hostname) - ip_obj = ipaddress.ip_address(ip_address) - - # Check if the IP is private/internal - if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_multicast: - return JsonResponse({"error": "Access to internal/private networks is not allowed"}, status=403) - except (socket.gaierror, ValueError): - # If hostname resolution fails or IP parsing fails, continue - pass - - except Exception as e: - return JsonResponse({"error": f"Invalid URL format: {str(e)}"}, status=400) - - # Set a timeout to prevent hanging requests - timeout = 5 # seconds - - try: - # Only allow HEAD and GET methods with limited redirects - response = requests.get( - url, - timeout=timeout, - allow_redirects=True, - headers={ - "User-Agent": "Educational-Website-Validator/1.0", - }, - ) - response.raise_for_status() - - # Extract title from response headers or content - title = response.headers.get("title", "") - if not title: - # Try to extract title from HTML content - content = response.text - title_match = re.search(r"(.*?)", content, re.IGNORECASE | re.DOTALL) - title = title_match.group(1).strip() if title_match else "Untitled Video" - - # Sanitize the title - title = html.escape(title) - - return JsonResponse({"title": title}) - - except requests.RequestException as e: - logger.error(f"Error fetching video title from {url}: {str(e)}") - return JsonResponse({"error": f"Failed to fetch video title: {str(e)}"}, status=500) - except Exception as e: - logger.error(f"Unexpected error fetching video title from {url}: {str(e)}") - return JsonResponse({"error": "An unexpected error occurred while fetching the title"}, status=500) - - -def get_referral_stats(): - """Get statistics for top referrers.""" - return ( - Profile.objects.annotate( - total_signups=Count("referrals"), - total_enrollments=Count( - "referrals__user__enrollments", filter=Q(referrals__user__enrollments__status="approved") - ), - total_clicks=Count( - "referrals__user__webrequest", filter=Q(referrals__user__webrequest__path__contains="ref=") - ), - ) - .filter(total_signups__gt=0) - .order_by("-total_signups")[:10] - ) - - -def referral_leaderboard(request): - """Display the referral leaderboard.""" - top_referrers = get_referral_stats() - return render(request, "web/referral_leaderboard.html", {"top_referrers": top_referrers}) - - -# Goods Views -class GoodsListView(LoginRequiredMixin, generic.ListView): - model = Goods - template_name = "goods/goods_list.html" - context_object_name = "products" - - def get_queryset(self): - return Goods.objects.filter(storefront__teacher=self.request.user) - - -class GoodsDetailView(generic.DetailView): - model = Goods - template_name = "goods/goods_detail.html" - context_object_name = "product" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["product_images"] = self.object.goods_images.all() # Get all images related to the product - context["other_products"] = Goods.objects.exclude(pk=self.object.pk)[:12] # Fetch other products - view_data = WebRequest.objects.filter(path=self.request.path).aggregate(total_views=Coalesce(Sum("count"), 0)) - context["view_count"] = view_data["total_views"] - - # Add cart count for each product - products_with_cart_count = [] - for product in context["other_products"]: - product.cart_count = product.cart_items.count() - products_with_cart_count.append(product) - - context["other_products"] = products_with_cart_count - - return context - - -class GoodsCreateView(LoginRequiredMixin, UserPassesTestMixin, generic.CreateView): - model = Goods - form_class = GoodsForm - template_name = "goods/goods_form.html" - - def test_func(self): - return hasattr(self.request.user, "storefront") - - def form_valid(self, form): - form.instance.storefront = self.request.user.storefront - images = self.request.FILES.getlist("images") - product_type = form.cleaned_data.get("product_type") - - # Validate digital product images - if product_type == "digital" and not images: - form.add_error(None, "Digital products require at least one image") - return self.form_invalid(form) - - # Validate image constraints - if len(images) > 8: - form.add_error(None, "Maximum 8 images allowed") - return self.form_invalid(form) - - for img in images: - if img.size > 5 * 1024 * 1024: - form.add_error(None, f"{img.name} exceeds 5MB size limit") - return self.form_invalid(form) - - # Save main product first - super().form_valid(form) - - # Save images after product creation - for image_file in images: - ProductImage.objects.create(goods=self.object, image=image_file) - - return render(self.request, "goods/goods_create_success.html", {"product": self.object}) - - def form_invalid(self, form): - messages.error(self.request, f"Creation failed: {form.errors.as_text()}") - return super().form_invalid(form) - - def get_success_url(self): - return reverse("goods_list") - - -class GoodsUpdateView(LoginRequiredMixin, UserPassesTestMixin, generic.UpdateView): - model = Goods - form_class = GoodsForm - template_name = "goods/goods_update.html" - - # Filter by user's products only - def get_queryset(self): - return Goods.objects.filter(storefront__teacher=self.request.user) - - # Verify ownership - def test_func(self): - return self.get_object().storefront.teacher == self.request.user - - def get_success_url(self): - return reverse("goods_list") - - -class GoodsDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): - model = Goods - template_name = "goods/goods_confirm_delete.html" - success_url = reverse_lazy("goods_list") - - def test_func(self): - return self.request.user == self.get_object().storefront.teacher - - -def add_goods_to_cart(request, pk): - """Add a product (goods) to the cart.""" - product = get_object_or_404(Goods, pk=pk) - - # Prevent adding out-of-stock items - if product.stock is None or product.stock <= 0: - messages.error(request, f"{product.name} is out of stock and cannot be added to cart.") - return redirect("goods_detail", pk=pk) # Redirect back to product page - - cart = get_or_create_cart(request) - cart_item, created = CartItem.objects.get_or_create(cart=cart, goods=product, defaults={"session": None}) - - if created: - messages.success(request, f"{product.name} added to cart.") - else: - messages.info(request, f"{product.name} is already in your cart.") - - return redirect("cart_view") - - -class GoodsListingView(ListView): - model = Goods - template_name = "goods/goods_listing.html" - context_object_name = "products" - paginate_by = 15 - - def get_queryset(self): - queryset = Goods.objects.all() - store_name = self.request.GET.get("store_name") - product_type = self.request.GET.get("product_type") - category = self.request.GET.get("category") - min_price = self.request.GET.get("min_price") - max_price = self.request.GET.get("max_price") - - if store_name: - queryset = queryset.filter(storefront__name__icontains=store_name) - if product_type: - queryset = queryset.filter(product_type=product_type) - if category: - queryset = queryset.filter(category__icontains=category) - if min_price: - queryset = queryset.filter(price__gte=min_price) - if max_price: - queryset = queryset.filter(price__lte=max_price) - - return queryset - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["store_names"] = Storefront.objects.values_list("name", flat=True).distinct() - context["categories"] = Goods.objects.values_list("category", flat=True).distinct() - - # Add cart count for each product - products_with_cart_count = [] - for product in context["products"]: - product.cart_count = product.cart_items.count() - products_with_cart_count.append(product) - - context["products"] = products_with_cart_count - - return context - - -# Order Management -class OrderManagementView(LoginRequiredMixin, UserPassesTestMixin, generic.ListView): - model = Order - template_name = "orders/order_management.html" - context_object_name = "orders" - paginate_by = 20 - - def test_func(self): - storefront = get_object_or_404(Storefront, store_slug=self.kwargs["store_slug"]) - return self.request.user == storefront.teacher - - def get_queryset(self): - queryset = Order.objects.filter(items__goods__storefront__store_slug=self.kwargs["store_slug"]).distinct() - - # Get status from request and filter - selected_status = self.request.GET.get("status") - if selected_status and selected_status != "all": - queryset = queryset.filter(status=selected_status) - - return queryset - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["statuses"] = Order.STATUS_CHOICES # Directly from model - context["selected_status"] = self.request.GET.get("status", "") - return context - - -class OrderDetailView(LoginRequiredMixin, generic.DetailView): - model = Order - template_name = "orders/order_detail.html" - context_object_name = "order" - - -@login_required -@require_POST -def update_order_status(request, item_id): - order = get_object_or_404(Order, id=item_id, user=request.user) - new_status = request.POST.get("status").lower() # Convert to lowercase for consistency - - # Define allowed statuses inside the function - VALID_STATUSES = ["draft", "pending", "processing", "shipped", "completed", "cancelled", "refunded"] - - if new_status not in VALID_STATUSES: - messages.error(request, "Invalid status.") - return redirect("order_detail", pk=item_id) - - order.status = new_status - order.save() - messages.success(request, "Order status updated successfully.") - return redirect("order_detail", pk=item_id) - - -# Analytics -class StoreAnalyticsView(LoginRequiredMixin, UserPassesTestMixin, generic.TemplateView): - template_name = "analytics/analytics_dashboard.html" - - def test_func(self): - storefront = get_object_or_404(Storefront, store_slug=self.kwargs["store_slug"]) - return self.request.user == storefront.teacher - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - storefront = get_object_or_404(Storefront, store_slug=self.kwargs["store_slug"]) - - # Store-specific analytics - orders = Order.objects.filter(storefront=storefront, status="completed") - - context.update( - { - "total_sales": orders.count(), - "total_revenue": orders.aggregate(Sum("total_price"))["total_price__sum"] or 0, - "top_products": OrderItem.objects.filter(order__storefront=storefront) - .values("goods__name") - .annotate(total_sold=Sum("quantity")) - .order_by("-total_sold")[:5], - "storefront": storefront, - } - ) - return context - - -class AdminMerchAnalyticsView(LoginRequiredMixin, UserPassesTestMixin, generic.TemplateView): - template_name = "analytics/admin_analytics.html" - - def test_func(self): - return self.request.user.is_staff - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # Platform-wide analytics - context.update( - { - "total_sales": Order.objects.filter(status="completed").count(), - "total_revenue": Order.objects.filter(status="completed").aggregate(Sum("total_price"))[ - "total_price__sum" - ] - or 0, - "top_storefronts": Storefront.objects.annotate(total_sales=Count("goods__orderitem")).order_by( - "-total_sales" - )[:5], - } - ) - return context - - -@login_required -def sales_analytics(request): - """View for displaying sales analytics.""" - storefront = get_object_or_404(Storefront, teacher=request.user) - - # Get completed orders for this storefront - orders = Order.objects.filter(storefront=storefront, status="completed") - - # Calculate metrics - total_revenue = orders.aggregate(total=Sum("total_price"))["total"] or 0 - total_orders = orders.count() - - # Placeholder conversion rate (to be implemented properly later) - conversion_rate = 0.00 # Temporary placeholder - - # Best selling products - best_selling_products = ( - OrderItem.objects.filter(order__storefront=storefront) - .values("goods__name") - .annotate(total_sold=Sum("quantity")) - .order_by("-total_sold")[:5] - ) - - context = { - "total_revenue": total_revenue, - "total_orders": total_orders, - "conversion_rate": conversion_rate, - "best_selling_products": best_selling_products, - } - return render(request, "analytics/analytics_dashboard.html", context) - - -@login_required -def sales_data(request): - # Get the user's storefront - storefront = get_object_or_404(Storefront, teacher=request.user) - - # Define valid statuses for metrics (e.g., include "completed" and "shipped") - valid_statuses = ["completed", "shipped"] - orders = Order.objects.filter(storefront=storefront, status__in=valid_statuses) - - # Calculate total revenue - total_revenue = orders.aggregate(total=Sum("total_price"))["total"] or 0 - - # Calculate total orders - total_orders = orders.count() - - # Calculate conversion rate (orders / visits * 100) - total_visits = WebRequest.objects.filter(path__contains="ref=").count() # Adjust based on visit tracking - conversion_rate = (total_orders / total_visits * 100) if total_visits > 0 else 0.00 - - # Get best-selling products - best_selling_products = ( - OrderItem.objects.filter(order__storefront=storefront, order__status__in=valid_statuses) - .values("goods__name") - .annotate(total_sold=Sum("quantity")) - .order_by("-total_sold")[:5] - ) - - # Prepare response data - data = { - "total_revenue": float(total_revenue), - "total_orders": total_orders, - "conversion_rate": round(conversion_rate, 2), - "best_selling_products": list(best_selling_products), - } - return JsonResponse(data) - - -class StorefrontCreateView(LoginRequiredMixin, CreateView): - model = Storefront - form_class = StorefrontForm - template_name = "storefront/storefront_form.html" - success_url = "/dashboard/teacher/" - - def dispatch(self, request, *args, **kwargs): - if Storefront.objects.filter(teacher=request.user).exists(): - return redirect("storefront_update", store_slug=request.user.storefront.store_slug) - return super().dispatch(request, *args, **kwargs) - - def form_valid(self, form): - form.instance.teacher = self.request.user # Set the teacher field to the current user - return super().form_valid(form) - - -class StorefrontUpdateView(LoginRequiredMixin, UpdateView): - model = Storefront - form_class = StorefrontForm - template_name = "storefront/storefront_form.html" - success_url = "/dashboard/teacher/" - - def get_object(self): - return get_object_or_404(Storefront, teacher=self.request.user) - - -class StorefrontDetailView(LoginRequiredMixin, generic.DetailView): - model = Storefront - template_name = "storefront/storefront_detail.html" - context_object_name = "storefront" - - def get_object(self): - return get_object_or_404(Storefront, store_slug=self.kwargs["store_slug"]) - - -def success_story_list(request): - """View for listing published success stories.""" - success_stories = SuccessStory.objects.filter(status="published").order_by("-published_at") - - # Paginate results - paginator = Paginator(success_stories, 9) # 9 stories per page - page_number = request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - context = { - "success_stories": page_obj, - "is_paginated": paginator.num_pages > 1, - "page_obj": page_obj, - } - return render(request, "success_stories/list.html", context) - - -def success_story_detail(request, slug): - """View for displaying a single success story.""" - success_story = get_object_or_404(SuccessStory, slug=slug, status="published") - - # Get related success stories (same author or similar content) - related_stories = ( - SuccessStory.objects.filter(status="published").exclude(id=success_story.id).order_by("-published_at")[:3] - ) - - context = { - "success_story": success_story, - "related_stories": related_stories, - } - return render(request, "success_stories/detail.html", context) - - -@login_required -def create_success_story(request): - """View for creating a new success story.""" - if request.method == "POST": - form = SuccessStoryForm(request.POST, request.FILES) - if form.is_valid(): - success_story = form.save(commit=False) - success_story.author = request.user - success_story.save() - messages.success(request, "Success story created successfully!") - return redirect("success_story_detail", slug=success_story.slug) - else: - form = SuccessStoryForm() - - context = { - "form": form, - } - return render(request, "success_stories/create.html", context) - - -@login_required -def edit_success_story(request, slug): - """View for editing an existing success story.""" - success_story = get_object_or_404(SuccessStory, slug=slug, author=request.user) - - if request.method == "POST": - form = SuccessStoryForm(request.POST, request.FILES, instance=success_story) - if form.is_valid(): - form.save() - messages.success(request, "Success story updated successfully!") - return redirect("success_story_detail", slug=success_story.slug) - else: - form = SuccessStoryForm(instance=success_story) - - context = { - "form": form, - "success_story": success_story, - "is_edit": True, - } - return render(request, "success_stories/create.html", context) - - -@login_required -def delete_success_story(request, slug): - """View for deleting a success story.""" - success_story = get_object_or_404(SuccessStory, slug=slug, author=request.user) - - if request.method == "POST": - success_story.delete() - messages.success(request, "Success story deleted successfully!") - return redirect("success_story_list") - - context = { - "success_story": success_story, - } - return render(request, "success_stories/delete_confirm.html", context) - - -def gsoc_landing_page(request): - """ - Renders the GSOC landing page with top GitHub contributors - based on merged pull requests - """ - import logging - - import requests - from django.conf import settings - - # Initialize an empty list for contributors in case the GitHub API call fails - top_contributors = [] - - # GitHub API URL for the education-website repository - github_repo_url = "https://api.github.com/repos/alphaonelabs/education-website" - - # Users to exclude from the contributor list (bots and automated users) - excluded_users = ["A1L13N", "dependabot[bot]"] - - try: - # Fetch contributors from GitHub API - headers = {} - # Check if GitHub token is configured - if hasattr(settings, "GITHUB_TOKEN") and settings.GITHUB_TOKEN: - headers["Authorization"] = f"token {settings.GITHUB_TOKEN}" - - # Get all closed pull requests - we'll filter for merged ones in code - # The GitHub API doesn't have a direct 'merged' filter in the query params - # so we get all closed PRs and then check the 'merged_at' field - pull_requests_response = requests.get( - f"{github_repo_url}/pulls", - params={ - "state": "closed", # closed PRs could be either merged or just closed - "sort": "updated", - "direction": "desc", - "per_page": 100, - }, - headers=headers, - timeout=5, - ) - - # Check for rate limiting - if pull_requests_response.status_code == 403 and "X-RateLimit-Remaining" in pull_requests_response.headers: - remaining = pull_requests_response.headers.get("X-RateLimit-Remaining") - if remaining == "0": - reset_time = int(pull_requests_response.headers.get("X-RateLimit-Reset", 0)) - reset_datetime = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(reset_time)) - logging.warning(f"GitHub API rate limit exceeded. Resets at {reset_datetime}") - - if pull_requests_response.status_code == 200: - pull_requests = pull_requests_response.json() - - # Create a map of contributors with their PR count - contributor_stats = defaultdict( - lambda: {"merged_pr_count": 0, "avatar_url": "", "profile_url": "", "prs_url": ""} - ) - - # Process each pull request - for pr in pull_requests: - # Check if the PR was merged - if pr.get("merged_at"): - username = pr["user"]["login"] - - # Skip excluded users - if username in excluded_users: - continue - - contributor_stats[username]["merged_pr_count"] += 1 - contributor_stats[username]["avatar_url"] = pr["user"]["avatar_url"] - contributor_stats[username]["profile_url"] = pr["user"]["html_url"] - # Add a direct link to the user's PRs for this repository - base_url = "https://github.com/alphaonelabs/education-website/pulls" - query = f"?q=is:pr+author:{username}+is:merged" - contributor_stats[username]["prs_url"] = base_url + query - contributor_stats[username]["username"] = username - - # Convert to list and sort by PR count - top_contributors = [v for k, v in contributor_stats.items()] - top_contributors.sort(key=lambda x: x["merged_pr_count"], reverse=True) - - # Get top 10 contributors - top_contributors = top_contributors[:10] - - except Exception as e: - logging.error(f"Error fetching GitHub contributors: {str(e)}") - - context = {"top_contributors": top_contributors} - - return render(request, "gsoc_landing_page.html", context) - - -def whiteboard(request): - return render(request, "whiteboard.html") - - -def graphing_calculator(request): - return render(request, "graphing_calculator.html") - - -def meme_list(request): - memes = Meme.objects.all().order_by("-created_at") - subjects = Subject.objects.filter(memes__isnull=False).distinct() - # Filter by subject if provided - subject_filter = request.GET.get("subject") - if subject_filter: - memes = memes.filter(subject__slug=subject_filter) - paginator = Paginator(memes, 12) # Show 12 memes per page - page_number = request.GET.get("page", 1) - page_obj = paginator.get_page(page_number) - - return render(request, "memes.html", {"memes": page_obj, "subjects": subjects, "selected_subject": subject_filter}) - - -def meme_detail(request: HttpRequest, slug: str) -> HttpResponse: - meme = get_object_or_404(Meme, slug=slug) - return render(request, "meme_detail.html", {"meme": meme}) - - -@login_required -def add_meme(request): - if request.method == "POST": - form = MemeForm(request.POST, request.FILES) - if form.is_valid(): - meme = form.save(commit=False) # The form handles subject creation logic internally - meme.uploader = request.user - meme.save() - messages.success(request, "Your meme has been uploaded successfully!") - return redirect("meme_list") - else: - form = MemeForm() - subjects = Subject.objects.all().order_by("name") - return render(request, "add_meme.html", {"form": form, "subjects": subjects}) - - -@login_required -def team_goals(request): - """List all team goals the user is part of or has created.""" - user_goals = ( - TeamGoal.objects.filter(Q(creator=request.user) | Q(members__user=request.user)) - .distinct() - .order_by("-created_at") - ) - - paginator = Paginator(user_goals, 10) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - pending_invites = TeamInvite.objects.filter(recipient=request.user, status="pending").select_related( - "goal", "sender" - ) - - context = { - "goals": page_obj, - "pending_invites": pending_invites, - "is_paginated": paginator.num_pages > 1, - } - return render(request, "teams/list.html", context) - - -@login_required -def create_team_goal(request): - """Create a new team goal.""" - if request.method == "POST": - form = TeamGoalForm(request.POST) - if form.is_valid(): - with transaction.atomic(): - goal = form.save(commit=False) - goal.creator = request.user - goal.save() - - # Add creator as a member - TeamGoalMember.objects.create(team_goal=goal, user=request.user, role="leader") - - messages.success(request, "Team goal created successfully!") - return redirect("team_goal_detail", goal_id=goal.id) - else: - form = TeamGoalForm() - - return render(request, "teams/create.html", {"form": form}) - - -@login_required -def team_goal_detail(request, goal_id): - """View and manage a specific team goal.""" - goal = get_object_or_404(TeamGoal.objects.prefetch_related("members__user"), id=goal_id) - - # Check if user has access to this goal - if not (goal.creator == request.user or goal.members.filter(user=request.user).exists()): - messages.error(request, "You do not have access to this team goal.") - return redirect("team_goals") - - # Get existing team members to exclude from invitation - existing_members = goal.members.values_list("user_id", flat=True) - - # Handle inviting new members - if request.method == "POST": - form = TeamInviteForm(request.POST) - if form.is_valid(): - # Check for existing invites using the validated User object - if TeamInvite.objects.filter( - goal__id=goal.id, recipient=form.cleaned_data["recipient"] # Changed to use User object - ).exists(): - messages.warning(request, "An invite for this user is already pending.") - return redirect("team_goal_detail", goal_id=goal.id) - invite = form.save(commit=False) - invite.sender = request.user - invite.goal = goal - invite.save() - messages.success(request, f"Invitation sent to {invite.recipient.email}!") - notify_team_invite(invite) - return redirect("team_goal_detail", goal_id=goal.id) - - else: - form = TeamInviteForm() - - # Get users that can be invited (exclude existing members and the creator) - available_users = User.objects.exclude(id__in=list(existing_members) + [goal.creator.id]).values( - "id", "username", "email" - ) - - context = { - "goal": goal, - "invite_form": form, - "user_is_leader": goal.members.filter(user=request.user, role="leader").exists(), - "available_users": available_users, - } - return render(request, "teams/detail.html", context) - - -@login_required -def accept_team_invite(request, invite_id): - """Accept a team invitation.""" - invite = get_object_or_404( - TeamInvite.objects.select_related("goal"), id=invite_id, recipient=request.user, status="pending" - ) - - # Create team member using get_or_create to avoid race conditions - member, created = TeamGoalMember.objects.get_or_create( - team_goal=invite.goal, user=request.user, defaults={"role": "member"} - ) - - if not created: - messages.info(request, f"You are already a member of {invite.goal.title}.") - else: - messages.success(request, f"You have joined {invite.goal.title}!") - - # Update invite status - invite.status = "accepted" - invite.responded_at = timezone.now() - invite.save() - - notify_team_invite_response(invite) - return redirect("team_goal_detail", goal_id=invite.goal.id) - - -@login_required -def decline_team_invite(request, invite_id): - """Decline a team invitation.""" - invite = get_object_or_404(TeamInvite, id=invite_id, recipient=request.user, status="pending") - - invite.status = "declined" - invite.responded_at = timezone.now() - invite.save() - - notify_team_invite_response(invite) - messages.info(request, f"You have declined to join {invite.goal.title}.") - return redirect("team_goals") - - -@login_required -def edit_team_goal(request, goal_id): - """Edit an existing team goal.""" - goal = get_object_or_404(TeamGoal, id=goal_id) - - # Check if user is the creator or a leader - if not (goal.creator == request.user or goal.members.filter(user=request.user, role="leader").exists()): - messages.error(request, "You don't have permission to edit this team goal.") - return redirect("team_goal_detail", goal_id=goal_id) - - if request.method == "POST": - form = TeamGoalForm(request.POST, instance=goal) - if form.is_valid(): - # Validate that deadline is not in the past - if form.cleaned_data["deadline"] < timezone.now(): - form.add_error("deadline", "Deadline cannot be in the past.") - context = { - "form": form, - "goal": goal, - "is_edit": True, - } - return render(request, "teams/create.html", context) - form.save() - messages.success(request, "Team goal updated successfully!") - return redirect("team_goal_detail", goal_id=goal.id) - else: - form = TeamGoalForm(instance=goal) - - context = { - "form": form, - "goal": goal, - "is_edit": True, - } - return render(request, "teams/create.html", context) - - -@login_required -def mark_team_contribution(request, goal_id): - """Allow a team member to mark their contribution as complete.""" - goal = get_object_or_404(TeamGoal, id=goal_id) - - # Find the current user's membership in this goal - member = goal.members.filter(user=request.user).first() - - if not member: - messages.error(request, "You are not a member of this team goal.") - return redirect("team_goal_detail", goal_id=goal_id) - - if member.completed: - messages.info(request, "Your contribution is already marked as complete.") - return redirect("team_goal_detail", goal_id=goal_id) - - # Mark the user's contribution as complete - member.mark_completed() - messages.success(request, "Your contribution has been marked as complete.") - notify_team_goal_completion(goal, request.user) - return redirect("team_goal_detail", goal_id=goal_id) - - -@login_required -def remove_team_member(request, goal_id, member_id): - """Remove a member from a team goal.""" - goal = get_object_or_404(TeamGoal, id=goal_id) - - # Check if user is the creator or a leader - if not (goal.creator == request.user or goal.members.filter(user=request.user, role="leader").exists()): - messages.error(request, "You don't have permission to remove members.") - return redirect("team_goal_detail", goal_id=goal_id) - - member = get_object_or_404(TeamGoalMember, id=member_id, team_goal=goal) - - # Prevent removing the creator - if member.user == goal.creator: - messages.error(request, "The team creator cannot be removed.") - return redirect("team_goal_detail", goal_id=goal_id) - - member.delete() - messages.success(request, f"{member.user.username} has been removed from the team.") - return redirect("team_goal_detail", goal_id=goal_id) - - -@login_required -def submit_team_proof(request, team_goal_id): - team_goal = get_object_or_404(TeamGoal, id=team_goal_id) - member = get_object_or_404(TeamGoalMember, team_goal=team_goal, user=request.user) - - if request.method == "POST": - form = TeamGoalCompletionForm(request.POST, request.FILES, instance=member) - if form.is_valid(): - form.save() - if not member.completed: - member.mark_completed() - return redirect("team_goal_detail", goal_id=team_goal.id) # Fixed here - - else: - form = TeamGoalCompletionForm(instance=member) - - return render(request, "teams/submit_proof.html", {"form": form, "team_goal": team_goal}) - - -@login_required -def delete_team_goal(request, goal_id): - """Delete a team goal.""" - goal = get_object_or_404(TeamGoal, id=goal_id) - - # Only creator can delete the goal - if request.user != goal.creator: - messages.error(request, "Only the creator can delete this team goal.") - return redirect("team_goal_detail", goal_id=goal_id) - - if request.method == "POST": - goal.delete() - messages.success(request, "Team goal has been deleted.") - return redirect("team_goals") - - return render(request, "teams/delete_confirm.html", {"goal": goal}) - - -@login_required -def virtual_classroom_list(request): - """View to list all virtual classrooms for the current user.""" - classrooms = VirtualClassroom.objects.filter(teacher=request.user) - return render( - request, - "virtual_classroom/list.html", - {"classrooms": classrooms, "user": request.user}, # Pass the user object which includes the profile - ) - - -@login_required -@require_POST -def join_global_virtual_classroom(request): - """Join (or create) the global virtual classroom and redirect to it.""" - - teacher = User.objects.filter(is_staff=True, is_active=True).order_by("-is_superuser", "date_joined").first() - - if not teacher: - messages.error(request, "No teacher is available to host the global virtual classroom yet.") - return redirect("index") - - classroom = ( - VirtualClassroom.objects.filter(name__iexact="Global Virtual Classroom", course__isnull=True) - .order_by("-created_at") - .first() - ) - - if not classroom: - classroom = VirtualClassroom.objects.create( - name="Global Virtual Classroom", - teacher=teacher, - is_active=True, - max_students=200, - ) - - # Ensure customization exists - VirtualClassroomCustomization.objects.get_or_create(classroom=classroom) - - # Add the current user as a participant - VirtualClassroomParticipant.objects.get_or_create(user=request.user, classroom=classroom) - - messages.success(request, "You're in! Welcome to the global virtual classroom.") - return redirect("virtual_classroom_detail", classroom_id=classroom.id) - - -@login_required -def virtual_classroom_create(request): - """View to create a new virtual classroom.""" - if request.method == "POST": - form = VirtualClassroomForm(request.POST, user=request.user) - if form.is_valid(): - classroom = form.save(commit=False) - classroom.teacher = request.user - classroom.save() - - # Create default customization settings - VirtualClassroomCustomization.objects.get_or_create( - classroom=classroom, - defaults={ - "wall_color": "#E6E2D7", - "floor_color": "#C7B299", - "desk_color": "#8B4513", - "chair_color": "#4B0082", - "board_color": "#005C53", - "number_of_rows": 5, - "desks_per_row": 6, - "has_plants": True, - "has_windows": True, - "has_bookshelf": True, - "has_clock": True, - "has_carpet": True, - }, - ) - - messages.success(request, "Virtual classroom created successfully!") - return redirect("virtual_classroom_customize", classroom_id=classroom.id) - else: - form = VirtualClassroomForm(user=request.user) - - return render(request, "virtual_classroom/create.html", {"form": form}) - - -@login_required -def virtual_classroom_customize(request, classroom_id): - """View to customize a virtual classroom.""" - classroom = get_object_or_404(VirtualClassroom, id=classroom_id, teacher=request.user) - - # Get or create customization settings - customization, created = VirtualClassroomCustomization.objects.get_or_create(classroom=classroom) - - if request.method == "POST": - form = VirtualClassroomCustomizationForm(request.POST, instance=customization) - if form.is_valid(): - form.save() - messages.success(request, "Classroom customization saved successfully!") - return redirect("virtual_classroom_detail", classroom_id=classroom.id) - else: - form = VirtualClassroomCustomizationForm(instance=customization) - - return render(request, "virtual_classroom/customize.html", {"form": form, "classroom": classroom}) - - -@login_required -def virtual_classroom_detail(request, classroom_id): - """View to display a virtual classroom.""" - classroom = get_object_or_404(VirtualClassroom, id=classroom_id) - - # Check if user is teacher or enrolled student - is_teacher = request.user == classroom.teacher - is_enrolled = False - - if classroom.course: - # For classrooms with a course, check course enrollments - is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() - else: - # For standalone classrooms, check VirtualClassroomParticipant table - is_enrolled = VirtualClassroomParticipant.objects.filter(classroom=classroom, user=request.user).exists() - - if not (is_teacher or is_enrolled): - messages.error(request, "You do not have access to this virtual classroom.") - if classroom.course: - return redirect("course_detail", slug=classroom.course.slug) - else: - return redirect("virtual_classroom_list") - - # Get or create customization settings to prevent DoesNotExist errors - customization, created = VirtualClassroomCustomization.objects.get_or_create( - classroom=classroom, - defaults={ - "wall_color": "#E6E2D7", - "floor_color": "#C7B299", - "desk_color": "#8B4513", - "chair_color": "#4B0082", - "board_color": "#005C53", - "number_of_rows": 5, - "desks_per_row": 6, - "has_plants": True, - "has_windows": True, - "has_bookshelf": True, - "has_clock": True, - "has_carpet": True, - }, - ) - - if request.method == "POST" and request.headers.get("X-Requested-With") == "XMLHttpRequest": - if not is_teacher: # Only teachers can customize - return JsonResponse({"status": "error", "message": "Only teachers can customize the classroom"}, status=403) - - try: - data = json.loads(request.body.decode("utf-8") or "{}") - - # Update customization settings - customization.wall_color = data.get("wall_color", customization.wall_color) - customization.floor_color = data.get("floor_color", customization.floor_color) - customization.desk_color = data.get("desk_color", customization.desk_color) - customization.chair_color = data.get("chair_color", customization.chair_color) - customization.board_color = data.get("board_color", customization.board_color) - customization.number_of_rows = data.get("number_of_rows", customization.number_of_rows) - customization.desks_per_row = data.get("desks_per_row", customization.desks_per_row) - customization.has_plants = data.get("has_plants", customization.has_plants) - customization.has_windows = data.get("has_windows", customization.has_windows) - customization.has_bookshelf = data.get("has_bookshelf", customization.has_bookshelf) - customization.has_clock = data.get("has_clock", customization.has_clock) - customization.has_carpet = data.get("has_carpet", customization.has_carpet) - - customization.save() - return JsonResponse({"status": "success"}) - except json.JSONDecodeError: - return JsonResponse({"status": "error", "message": "Invalid JSON data"}, status=400) - except Exception: - # Log the detailed exception for debugging - logger.exception("Error in virtual_classroom_detail customization") - return JsonResponse({"status": "error", "message": "An internal error occurred"}, status=500) - - # Get participants for the classroom - participants = VirtualClassroomParticipant.objects.filter(classroom=classroom).select_related("user") - - return render( - request, - "virtual_classroom/index.html", - { - "classroom": classroom, - "customization": customization, - "is_teacher": is_teacher, - "is_enrolled": is_enrolled, - "participants": participants, - }, - ) - - -@login_required -def virtual_classroom_edit(request, classroom_id): - """View to edit a virtual classroom.""" - classroom = get_object_or_404(VirtualClassroom, id=classroom_id, teacher=request.user) - - if request.method == "POST": - form = VirtualClassroomForm(request.POST, instance=classroom, user=request.user) - if form.is_valid(): - form.save() - messages.success(request, "Virtual classroom updated successfully!") - return redirect("virtual_classroom_detail", classroom_id=classroom.id) - else: - form = VirtualClassroomForm(instance=classroom, user=request.user) - - return render(request, "virtual_classroom/edit.html", {"form": form, "classroom": classroom}) - - -@login_required -def virtual_classroom_delete(request, classroom_id): - """View to delete a virtual classroom.""" - classroom = get_object_or_404(VirtualClassroom, id=classroom_id, teacher=request.user) - - if request.method == "POST": - classroom.delete() - messages.success(request, "Virtual classroom deleted successfully!") - return redirect("virtual_classroom_list") - - return render(request, "virtual_classroom/delete.html", {"classroom": classroom}) - - -@login_required -def classroom_blackboard(request, classroom_id): - """View for the classroom blackboard interaction.""" - classroom = get_object_or_404(VirtualClassroom, id=classroom_id) - - # Check if user is teacher or enrolled student - is_teacher = request.user == classroom.teacher - is_enrolled = False - if classroom.course: - is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() - - if not (is_teacher or is_enrolled): - messages.error(request, "You do not have access to this virtual classroom.") - return redirect("virtual_classroom_list") - - return render( - request, - "virtual_classroom/blackboard.html", - {"classroom": classroom, "is_teacher": is_teacher, "is_enrolled": is_enrolled}, - ) - - -@login_required -def classroom_student_desk(request, classroom_id, seat_id): - """View for individual student desk interaction.""" - classroom = get_object_or_404(VirtualClassroom, id=classroom_id) - - # Check if user is teacher or enrolled student - is_teacher = request.user == classroom.teacher - is_enrolled = False - if classroom.course: - is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() - - if not (is_teacher or is_enrolled): - messages.error(request, "You do not have access to this virtual classroom.") - return redirect("virtual_classroom_list") - - return render( - request, - "virtual_classroom/student_desk.html", - {"classroom": classroom, "seat_id": seat_id, "is_teacher": is_teacher, "is_enrolled": is_enrolled}, - ) - - -@login_required -@require_POST -def reset_attendance(request, classroom_id): - """Reset today's attendance for a classroom.""" - try: - classroom = get_object_or_404(VirtualClassroom, id=classroom_id) - - # Check if user is the teacher - if request.user != classroom.teacher: - messages.error(request, "Only the teacher can reset attendance.") - return redirect("classroom_attendance", classroom_id=classroom_id) - - # Get today's session and delete all attendance records - today = timezone.now().date() - today_start = timezone.make_aware(datetime.combine(today, datetime.min.time())) - today_end = timezone.make_aware(datetime.combine(today, datetime.max.time())) - - if classroom.course: - # For classrooms with courses, find and delete attendance for today's session - session = Session.objects.filter( - course=classroom.course, start_time__range=(today_start, today_end) - ).first() - - if session: - SessionAttendance.objects.filter(session=session).delete() - else: - # For classrooms without courses, find and delete standalone session attendance - session = Session.objects.filter( - title=f"Class on {today.strftime('%Y-%m-%d')}", start_time__range=(today_start, today_end) - ).first() - - if session: - SessionAttendance.objects.filter(session=session).delete() - - messages.success(request, "Today's attendance has been reset.") - return redirect("classroom_attendance", classroom_id=classroom_id) - - except Exception: - messages.error(request, "An error occurred while resetting attendance.") - return redirect("classroom_attendance", classroom_id=classroom_id) - - -@login_required -def classroom_attendance(request, classroom_id): - """View for managing classroom attendance.""" - classroom = get_object_or_404(VirtualClassroom, id=classroom_id) - - # Check if user is teacher or enrolled student - is_teacher = request.user == classroom.teacher - is_enrolled = False - - # Get all enrolled students - enrolled_students = [] - if classroom.course: - is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() - enrolled_students = ( - User.objects.filter(enrollments__course=classroom.course, enrollments__status="approved") - .select_related("profile") - .order_by("first_name", "last_name") - .distinct() - ) - else: - # For classrooms without a course, everyone is "enrolled" - is_enrolled = True - # Get users who are participants in this classroom (excluding teacher) - participant_user_ids = VirtualClassroomParticipant.objects.filter(classroom=classroom).values_list( - "user_id", flat=True - ) - - enrolled_students = ( - User.objects.filter(id__in=participant_user_ids) - .exclude(id=classroom.teacher.id) - .select_related("profile") - .order_by("first_name", "last_name") - .distinct() - ) - - if not (is_teacher or is_enrolled): - messages.error(request, "You do not have access to this virtual classroom.") - return redirect("virtual_classroom_list") - - # Get today's attendance records - today = timezone.now().date() - today_start = timezone.make_aware(datetime.combine(today, datetime.min.time())) - today_end = timezone.make_aware(datetime.combine(today, datetime.max.time())) - - # Get today's session - if classroom.course: - session = Session.objects.filter(course=classroom.course, start_time__range=(today_start, today_end)).first() - else: - session = Session.objects.filter( - title=f"Class on {today.strftime('%Y-%m-%d')}", start_time__range=(today_start, today_end) - ).first() - - # Get attendance records for today's session - attendance_records = ( - SessionAttendance.objects.filter(session=session, status="present").select_related("student") if session else [] - ) - - present_students = [record.student for record in attendance_records] - - context = { - "classroom": classroom, - "is_teacher": is_teacher, - "is_enrolled": is_enrolled, - "enrolled_students": enrolled_students, - "present_students": present_students, - "teacher": classroom.teacher, - } - - return render(request, "virtual_classroom/attendance.html", context) - - -@login_required -def update_student_attendance(request, classroom_id): - """View to update student attendance.""" - classroom = get_object_or_404(VirtualClassroom, id=classroom_id) - - # Check if user is teacher or enrolled student - is_teacher = request.user == classroom.teacher - is_enrolled = False - if classroom.course: - is_enrolled = classroom.course.enrollments.filter(student=request.user, status="approved").exists() - else: - is_enrolled = VirtualClassroomParticipant.objects.filter( - classroom=classroom, - user=request.user, - ).exists() - - if not (is_teacher or is_enrolled): - messages.error(request, "You do not have access to this virtual classroom.") - return redirect("virtual_classroom_list") - - if request.method == "POST": - try: - # Attempt to parse JSON payload; if it fails assume regular form submission - try: - data = json.loads(request.body.decode("utf-8") or "{}") - except json.JSONDecodeError: - data = {} - - student_id = data.get("student_id") or request.POST.get("student_id") - status = data.get("status") or request.POST.get("status") or "present" - - # If student_id is still missing, default to the current user (self-marking) - if not student_id: - student_id = request.user.id - - if not status: - status = "present" - - # Validate status value - allowed_status = {"present", "absent", "late", "excused"} - if status not in allowed_status: - return JsonResponse({"status": "error", "message": "Invalid status value"}, status=400) - - student = get_object_or_404(User, id=student_id) - - # Check if student is enrolled in the classroom - if classroom.course: - if not classroom.course.enrollments.filter(student=student, status="approved").exists(): - return JsonResponse( - {"status": "error", "message": "Student is not enrolled in this classroom"}, - status=400, - ) - else: - if not VirtualClassroomParticipant.objects.filter(classroom=classroom, user=student).exists(): - return JsonResponse( - {"status": "error", "message": "Student is not enrolled in this classroom"}, - status=400, - ) - - # Get today's session - today = timezone.now().date() - today_start = timezone.make_aware(datetime.combine(today, datetime.min.time())) - today_end = timezone.make_aware(datetime.combine(today, datetime.max.time())) - - if classroom.course: - session = Session.objects.filter( - course=classroom.course, start_time__range=(today_start, today_end) - ).first() - else: - session = Session.objects.filter( - title=f"Class on {today.strftime('%Y-%m-%d')}", start_time__range=(today_start, today_end) - ).first() - - if not session: - # Automatically create today's session if it doesn't exist - if classroom.course: - session = Session.objects.create( - course=classroom.course, - title=f"Class on {today.strftime('%Y-%m-%d')}", - start_time=today_start, - end_time=today_end, - ) - else: - session = Session.objects.create( - title=f"Class on {today.strftime('%Y-%m-%d')}", - start_time=today_start, - end_time=today_end, - course=None, - ) - - # Update or create attendance record - attendance, created = SessionAttendance.objects.get_or_create( - session=session, student=student, defaults={"status": status} - ) - - if not created: - attendance.status = status - attendance.save() - - is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" - if is_ajax: - return JsonResponse({"status": "success"}) - - messages.success(request, "Attendance marked successfully.") - return redirect("classroom_attendance", classroom_id=classroom_id) - except json.JSONDecodeError: - return JsonResponse({"status": "error", "message": "Invalid JSON data"}, status=400) - except Exception: - # Log the detailed exception for debugging - logger.exception("Error in update_student_attendance") - return JsonResponse({"status": "error", "message": "An internal error occurred"}, status=500) - - return JsonResponse({"status": "error", "message": "Invalid request method"}, status=400) - - -@login_required -def get_student_attendance(request): - """Get a student's attendance data for a specific course.""" - if not request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({"success": False, "message": "Invalid request"}, status=400) - - student_id = request.GET.get("student_id") - course_id = request.GET.get("course_id") - - if not all([student_id, course_id]): - return JsonResponse({"success": False, "message": "Missing required parameters"}, status=400) - - try: - course = Course.objects.get(id=course_id) - student = User.objects.get(id=student_id) - - # Check if user is authorized (must be the course teacher) - if request.user != course.teacher: - return JsonResponse( - {"success": False, "message": "Unauthorized: Only the course teacher can view this data"}, status=403 - ) - - # Get all attendance records for this student in this course - attendance_records = SessionAttendance.objects.filter(student=student, session__course=course).select_related( - "session" - ) - - # Format the data for the frontend - attendance_data = {} - for record in attendance_records: - attendance_data[record.session.id] = { - "status": record.status, - "notes": record.notes, - "created_at": record.created_at.isoformat(), - "updated_at": record.updated_at.isoformat(), - } - - return JsonResponse({"success": True, "attendance": attendance_data}) - - except Course.DoesNotExist: - return JsonResponse({"success": False, "message": "Course not found"}, status=404) - except User.DoesNotExist: - return JsonResponse({"success": False, "message": "Student not found"}, status=404) - except Exception: - return JsonResponse({"success": False, "message": "Error: get_student_attendance"}, status=500) - - -@login_required -@teacher_required -def add_student_to_course(request, slug): - course = get_object_or_404(Course, slug=slug) - if course.teacher != request.user: - return HttpResponseForbidden("You are not authorized to enroll students in this course.") - - # Check if course is full - if course.max_students and course.enrollments.count() >= course.max_students: - messages.error(request, "This course is full. Cannot enroll more students.") - return redirect("course_detail", slug=course.slug) - - if request.method == "POST": - form = StudentEnrollmentForm(request.POST) - if form.is_valid(): - email = form.cleaned_data["email"] - first_name = form.cleaned_data["first_name"] - last_name = form.cleaned_data["last_name"] - - # Try to find existing user - student = User.objects.filter(email=email).first() - - if student: - # Check if student is already enrolled - if Enrollment.objects.filter(course=course, student=student).exists(): - form.add_error(None, "This student is already enrolled in the course.") - else: - # Enroll existing student - enrollment = Enrollment.objects.create(course=course, student=student, status="approved") - messages.success(request, f"{student.get_full_name()} has been enrolled in the course.") - - # Send enrollment notifications - send_enrollment_confirmation(enrollment) - notify_teacher_new_enrollment(enrollment) - - # Send enrollment notification email to existing student - context = {"student": student, "course": course, "teacher": request.user, "is_existing_user": True} - html_message = render_to_string("emails/student_enrollment.html", context) - send_mail( - f"You have been enrolled in {course.title}", - f"You have been enrolled in {course.title} " - f"by {request.user.get_full_name() or request.user.username}.", - settings.DEFAULT_FROM_EMAIL, - [email], - html_message=html_message, - fail_silently=False, - ) - return redirect("course_detail", slug=course.slug) - else: - # Create new student account - try: - # Generate a unique username - timestamp = timezone.now().strftime("%Y%m%d%H%M%S") - generated_username = f"user_{timestamp}" - while User.objects.filter(username=generated_username).exists(): - generated_username = f"user_{timestamp}_{get_random_string(6)}" - - # Create new user - random_password = get_random_string(10) - student = User.objects.create_user( - username=generated_username, - email=email, - password=random_password, - first_name=first_name, - last_name=last_name, - ) - student.profile.is_teacher = False - student.profile.save() - - # Enroll the new student - enrollment = Enrollment.objects.create(course=course, student=student, status="approved") - messages.success(request, f"{first_name} {last_name} has been enrolled in the course.") - - # Send enrollment notifications - send_enrollment_confirmation(enrollment) - notify_teacher_new_enrollment(enrollment) - - # Send enrollment notification and password reset link to new student - reset_link = request.build_absolute_uri(reverse("account_reset_password")) - context = { - "student": student, - "course": course, - "teacher": request.user, - "reset_link": reset_link, - "is_existing_user": False, - } - html_message = render_to_string("emails/student_enrollment.html", context) - send_mail( - f"You have been enrolled in {course.title}", - f"You have been enrolled in {course.title} " - f"by {request.user.get_full_name() or request.user.username}. " - f"Please visit {reset_link} to set your password.", - settings.DEFAULT_FROM_EMAIL, - [email], - html_message=html_message, - fail_silently=False, - ) - return redirect("course_detail", slug=course.slug) - except IntegrityError: - form.add_error(None, "Failed to create user account. Please try again.") - else: - form = StudentEnrollmentForm() - - return render(request, "courses/add_student.html", {"form": form, "course": course}) - - -def donate(request): - """Display the donation page with options for one-time donations and subscriptions.""" - # Get recent public donations to display - recent_donations = Donation.objects.filter(status="completed", anonymous=False).order_by("-created_at")[:5] - - # Calculate total donations - total_donations = Donation.objects.filter(status="completed").aggregate(total=Sum("amount"))["total"] or 0 - - # Preset donation amounts for buttons - donation_amounts = [5, 10, 25, 50, 100] - - context = { - "stripe_public_key": settings.STRIPE_PUBLISHABLE_KEY, - "recent_donations": recent_donations, - "total_donations": total_donations, - "donation_amounts": donation_amounts, - } - - return render(request, "donate.html", context) - - -@csrf_exempt # Add CSRF exemption for Stripe -def create_donation_payment_intent(request: HttpRequest) -> JsonResponse: - """Create a payment intent for a one-time donation with multiple payment methods.""" - if request.method != "POST": - return JsonResponse({"error": "Invalid request method"}, status=400) - - try: - data = json.loads(request.body) - amount = data.get("amount") - message = data.get("message", "") - anonymous = data.get("anonymous", False) - email = data.get("email", "") - - if not amount or float(amount) <= 0: - return JsonResponse({"error": "Invalid donation amount"}, status=400) - - if not email: - return JsonResponse({"error": "Email is required"}, status=400) - - # Convert amount to cents for Stripe - amount_cents = int(float(amount) * 100) - - # Create a payment intent with multiple payment method types - intent = stripe.PaymentIntent.create( - amount=amount_cents, - currency="usd", - automatic_payment_methods={"enabled": True, "allow_redirects": "always"}, - receipt_email=email, - metadata={ - "donation_type": "one_time", - "user_id": str(request.user.id) if request.user.is_authenticated else None, - "message": message[:100] if message else "", - "anonymous": "true" if anonymous else "false", - "email": email, - }, - ) - - # Create a donation record - donation = Donation.objects.create( - user=request.user if request.user.is_authenticated else None, - email=email, - amount=amount, - donation_type="one_time", - status="pending", - stripe_payment_intent_id=intent.id, - message=message, - anonymous=anonymous, - ) - - return JsonResponse({"clientSecret": intent.client_secret, "donation_id": donation.id}) - - except Exception as e: - # Log the detailed exception for debugging - logger.exception("Error in create_donation_payment_intent: %s", str(e)) - return JsonResponse({"error": "An internal error occurred"}, status=400) - - -@csrf_exempt -def create_donation_subscription(request: HttpRequest) -> JsonResponse: - """Create a subscription for recurring donations with multiple payment methods.""" - if request.method != "POST": - return JsonResponse({"error": "Invalid request method"}, status=400) - - try: - data = json.loads(request.body) - amount = data.get("amount") - message = data.get("message", "") - anonymous = data.get("anonymous", False) - email = data.get("email", "") - - if not amount or float(amount) <= 0: - return JsonResponse({"error": "Invalid donation amount"}, status=400) - - if not email: - return JsonResponse({"error": "Email is required"}, status=400) - - # Convert amount to cents for Stripe - amount_cents = int(float(amount) * 100) - - # Create or get customer - customer = None - # Try to retrieve existing customer for authenticated users - if request.user.is_authenticated: - try: - membership = request.user.membership - if membership.stripe_customer_id: - customer = stripe.Customer.retrieve(membership.stripe_customer_id) - except (UserMembership.DoesNotExist, stripe.error.InvalidRequestError): - # No membership or invalid customer ID - customer = None - - # Create new customer if needed - if not customer: - customer = stripe.Customer.create( - email=email, - metadata={ - "user_id": str(request.user.id) if request.user.is_authenticated else None, - }, - ) - if request.user.is_authenticated: - # Store customer ID in user membership - membership, _ = UserMembership.objects.get_or_create( - user=request.user, defaults={"plan_id": 1, "stripe_customer_id": customer.id} - ) - if not membership.stripe_customer_id: - membership.stripe_customer_id = customer.id - membership.save() - - # Create a PaymentIntent for the first payment with setup_future_usage - payment_intent = stripe.PaymentIntent.create( - amount=amount_cents, - currency="usd", - customer=customer.id, - setup_future_usage="off_session", - automatic_payment_methods={"enabled": True, "allow_redirects": "always"}, - metadata={ - "donation_type": "subscription", - "user_id": str(request.user.id) if request.user.is_authenticated else None, - "message": message[:100] if message else "", - "anonymous": "true" if anonymous else "false", - "email": email, - }, - ) - - # Create donation record - donation = Donation.objects.create( - user=request.user if request.user.is_authenticated else None, - email=email, - amount=amount, - donation_type="subscription", - status="pending", - stripe_payment_intent_id=payment_intent.id, - stripe_customer_id=customer.id, - message=message, - anonymous=anonymous, - ) - - return JsonResponse({"clientSecret": payment_intent.client_secret, "donation_id": donation.id}) - - except Exception as e: - return JsonResponse({"error": str(e)}, status=400) - - -@csrf_exempt -def donation_webhook(request): - """Handle Stripe webhooks for donations.""" - payload = request.body - sig_header = request.META.get("HTTP_STRIPE_SIGNATURE") - webhook_secret = settings.STRIPE_WEBHOOK_SECRET - - try: - event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret) - logger.info("Received Stripe webhook: %s", event.type) - except ValueError as e: - logger.exception("Invalid payload: %s", str(e)) - return HttpResponse(status=400) - except stripe.error.SignatureVerificationError as e: - logger.exception("Invalid signature: %s", str(e)) - return HttpResponse(status=400) - - try: - # Handle payment intent events - if event.type == "payment_intent.succeeded": - payment_intent = event.data.object - logger.info("Processing successful payment: %s", payment_intent.id) - handle_successful_donation_payment(payment_intent) - elif event.type == "payment_intent.payment_failed": - payment_intent = event.data.object - logger.info("Processing failed payment: %s", payment_intent.id) - handle_failed_donation_payment(payment_intent) - - # Handle subscription events - elif event.type == "customer.subscription.created": - subscription = event.data.object - logger.info("Processing new subscription: %s", subscription.id) - handle_subscription_created(subscription) - elif event.type == "customer.subscription.updated": - subscription = event.data.object - logger.info("Processing subscription update: %s", subscription.id) - handle_subscription_updated(subscription) - elif event.type == "customer.subscription.deleted": - subscription = event.data.object - logger.info("Processing subscription cancellation: %s", subscription.id) - handle_subscription_cancelled(subscription) - elif event.type == "invoice.payment_succeeded": - invoice = event.data.object - logger.info("Processing successful invoice payment: %s", invoice.id) - handle_invoice_paid(invoice) - elif event.type == "invoice.payment_failed": - invoice = event.data.object - logger.info("Processing failed invoice payment: %s", invoice.id) - handle_invoice_failed(invoice) - - return HttpResponse(status=200) - except Exception: - logger.exception("Error processing webhook %s", event.type) - return HttpResponse(status=500) - - -def handle_successful_donation_payment(payment_intent: stripe.PaymentIntent) -> None: - """Handle successful one-time donation payments.""" - try: - logger.info("Finding donation for payment intent: %s", payment_intent.id) - # Find the donation by payment intent ID - donation = Donation.objects.get(stripe_payment_intent_id=payment_intent.id) - - # Only update if not already completed - if donation.status != "completed": - logger.info("Marking donation %s as completed", donation.id) - donation.status = "completed" - donation.save() - - # Send thank you email - send_donation_thank_you_email(donation) - else: - logger.info("Donation %s already completed", donation.id) - - except Donation.DoesNotExist: - logger.warning( - ( - "No donation found for payment intent: %s. This may indicate a payment intended for another system " - "or a database inconsistency." - ), - payment_intent.id, - ) - except Exception: - logger.exception("Error handling successful payment %s", payment_intent.id) - raise # Re-raise to allow webhook to handle the error - - -def handle_failed_donation_payment(payment_intent): - """Handle failed one-time donation payments.""" - try: - # Find the donation by payment intent ID - donation = Donation.objects.get(stripe_payment_intent_id=payment_intent.id) - donation.status = "failed" - donation.save() - - except Donation.DoesNotExist: - # This might be a payment for something else - pass - - -def handle_subscription_created(subscription): - """Handle newly created subscriptions.""" - try: - # Find the donation by subscription ID - donation = Donation.objects.get(stripe_subscription_id=subscription.id) - donation.status = "completed" if subscription.status == "active" else "pending" - donation.save() - - except Donation.DoesNotExist: - # This might be a subscription for something else - pass - - -def handle_subscription_updated(subscription): - """Handle subscription updates.""" - try: - # Find the donation by subscription ID - donation = Donation.objects.get(stripe_subscription_id=subscription.id) - - # Update status based on subscription status - if subscription.status == "active": - donation.status = "completed" - elif subscription.status == "past_due": - donation.status = "pending" - elif subscription.status == "canceled": - donation.status = "cancelled" - - donation.save() - - except Donation.DoesNotExist: - # This might be a subscription for something else - pass - - -def handle_subscription_cancelled(subscription): - """Handle cancelled subscriptions.""" - try: - # Find the donation by subscription ID - donation = Donation.objects.get(stripe_subscription_id=subscription.id) - donation.status = "cancelled" - donation.save() - - except Donation.DoesNotExist: - # This might be a subscription for something else - pass - - -def handle_invoice_paid(invoice): - """Handle successful subscription invoice payments.""" - if invoice.subscription: - try: - # Find the donation by subscription ID - donation = Donation.objects.get(stripe_subscription_id=invoice.subscription) - - # Create a new donation record for this payment - Donation.objects.create( - user=donation.user, - email=donation.email, - amount=donation.amount, - donation_type="subscription", - status="completed", - stripe_subscription_id=donation.stripe_subscription_id, - stripe_customer_id=donation.stripe_customer_id, - message=donation.message, - anonymous=donation.anonymous, - ) - - # Send thank you email - send_donation_thank_you_email(donation) - - except Donation.DoesNotExist: - # This might be a subscription for something else - pass - - -def handle_invoice_failed(invoice): - """Handle failed subscription invoice payments.""" - if invoice.subscription: - try: - # Find the donation by subscription ID - donation = Donation.objects.get(stripe_subscription_id=invoice.subscription) - - # Create a new donation record for this failed payment - Donation.objects.create( - user=donation.user, - email=donation.email, - amount=donation.amount, - donation_type="subscription", - status="failed", - stripe_subscription_id=donation.stripe_subscription_id, - stripe_customer_id=donation.stripe_customer_id, - message=donation.message, - anonymous=donation.anonymous, - ) - - except Donation.DoesNotExist: - # This might be a subscription for something else - pass - - -def send_donation_thank_you_email(donation): - """Send a thank you email for donations.""" - subject = "Thank You for Your Donation!" - from_email = settings.DEFAULT_FROM_EMAIL - to_email = donation.email - - # Prepare context for email template - context = { - "donation": donation, - "site_name": settings.SITE_NAME, - } - - # Render email template - html_message = render_to_string("emails/donation_thank_you.html", context) - plain_message = strip_tags(html_message) - - # Send email - send_mail(subject, plain_message, from_email, [to_email], html_message=html_message) - - -def donation_success(request): - """Display a success page after a successful donation.""" - donation_id = request.GET.get("donation_id") - payment_intent = request.GET.get("payment_intent") - redirect_status = request.GET.get("redirect_status") - - # Ensure we have the required donation_id - if not donation_id: - messages.error(request, "No donation information found.") - return redirect("donate") - - try: - donation = Donation.objects.get(id=donation_id) - - # If the redirect indicates success and a payment intent exists, verify with Stripe. - if redirect_status == "succeeded" and payment_intent: - # Double check with Stripe that the payment was successful - try: - # Retrieve PaymentIntent from Stripe to confirm payment status. - stripe_payment = stripe.PaymentIntent.retrieve(payment_intent) - if stripe_payment.status == "succeeded": - donation.status = "completed" - donation.save() - # Send thank you email if not already sent - send_donation_thank_you_email(donation) - except stripe.error.StripeError: - logger.exception("Error verifying payment intent") - - # Retry logic for temporary Stripe failures using Django's cache. - cache_key = f"retry_verify_payment_{payment_intent}" - retry_count = cache.get(cache_key, 0) - - if retry_count < 3: - cache.set(cache_key, retry_count + 1, 3600) # Retry after 1 hour - # You could enqueue a Celery task or use another background task system - # to retry the verification process - logger.warning("Retry %d/3 scheduled for payment verification: %s", retry_count + 1, payment_intent) - else: - logger.error("Max retries reached for payment verification: %s", payment_intent) - - # Continue to show the success page even if verification fails; - # the webhook will eventually update the status - - # If the donation status is now completed, show the success page. - if donation.status == "completed": - context = { - "donation": donation, - } - return render(request, "donation_success.html", context) - - # If donation status is not completed, alert the user and redirect. - messages.error(request, "Donation has not been successfully processed.") - return redirect("donate") - - except Donation.DoesNotExist: - messages.error(request, "Invalid donation ID.") - return redirect("donate") - - except stripe.error.StripeError: - logger.exception("Stripe error in donation_success view") - messages.error(request, "A payment processing error occurred. Please check your payment details.") - return redirect("donate") - - except Exception: - logger.exception("Unexpected error in donation_success view") - messages.error(request, "An unexpected error occurred while processing your donation.") - return redirect("donate") - - -def donation_cancel(request): - """Handle donation cancellation.""" - return redirect("donate") - - -def educational_videos_list(request: HttpRequest) -> HttpResponse: - """View for listing educational videos with requests included at the bottom.""" - # Get category filter from query params - selected_category = request.GET.get("category") - - # Base querysets - videos = EducationalVideo.objects.select_related("uploader", "category").order_by("-uploaded_at") - video_requests = VideoRequest.objects.select_related("requester", "category", "fulfilled_by").order_by( - "-created_at" - ) - - # Apply category filter if provided - if selected_category: - videos = videos.filter(category__slug=selected_category) - video_requests = video_requests.filter(category__slug=selected_category) - selected_category_obj = get_object_or_404(Subject, slug=selected_category) - selected_category_display = selected_category_obj.name - else: - selected_category_display = None - - # Paginate videos (9 per page) - paginator = Paginator(videos, 9) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - # Limit video requests to 5 - video_requests = video_requests[:5] - video_requests_paginated = VideoRequest.objects.count() > 5 - - # Category counts for sidebar - category_counts = dict( - EducationalVideo.objects.values("category__slug") - .annotate(count=Count("id")) - .values_list("category__slug", "count"), - ) - - # Get all subjects - subjects = Subject.objects.all().order_by("order", "name") - - context = { - "videos": page_obj, - "is_paginated": page_obj.has_other_pages(), - "page_obj": page_obj, - "subjects": subjects, - "selected_category": selected_category, - "selected_category_display": selected_category_display, - "category_counts": category_counts, - "video_requests": video_requests, - "video_requests_paginated": video_requests_paginated, - } - - return render(request, "videos/list.html", context) - - -def fetch_video_oembed(video_url): - """ - Hits YouTube or Vimeo's oEmbed endpoint and returns a dict - containing 'title' and 'description' (if available). - """ - # YouTube IDs are always 11 chars - yt_match = re.search(r"(?:v=|youtu\.be/|embed/|shorts/)([\w-]{11})", video_url) - if yt_match: - video_id = yt_match.group(1) - endpoint = f"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json" - else: - # Vimeo URLs look like vimeo.com/12345678… - vm_match = re.search(r"vimeo\.com/(?:video/)?(\d+)", video_url) - if vm_match: - endpoint = f"https://vimeo.com/api/oembed.json?url={video_url}" - else: - return {} - - try: - resp = requests.get(endpoint, timeout=3) - if resp.ok: - data = resp.json() - return { - "title": data.get("title", "").strip(), - "description": data.get("description", "").strip(), - } - except requests.RequestException: - pass - - return {} - - -def upload_educational_video(request): - """ - Handles GET → render form, POST → save video. - If user leaves title/description blank, we back‑fill from YouTube/Vimeo. - """ - if request.method == "POST": - form = EducationalVideoForm(request.POST) - if form.is_valid(): - video = form.save(commit=False) - if request.user.is_authenticated: - video.uploader = request.user - - # auto‑fetch metadata if missing - if not video.title.strip() or not video.description.strip(): - info = fetch_video_oembed(video.video_url) - if not video.title.strip() and info.get("title"): - video.title = info["title"] - if not video.description.strip() and info.get("description"): - video.description = info["description"] - - video.save() - - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({"success": True, "message": "Video added successfully!"}) - return redirect("educational_videos_list") - - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - error_text = " ".join(f"{fld}: {', '.join(errs)}." for fld, errs in form.errors.items()) - return JsonResponse({"success": False, "error": error_text}, status=400) - - else: - form = EducationalVideoForm() - - return render(request, "videos/upload.html", {"form": form}) - - -def certificate_detail(request, certificate_id): - certificate = get_object_or_404(Certificate, certificate_id=certificate_id) - if request.user != certificate.user and not request.user.is_staff: - return HttpResponseForbidden("You don't have permission to view this certificate") - context = { - "certificate": certificate, - } - return render(request, "courses/certificate_detail.html", context) - - -@login_required -def generate_certificate(request, enrollment_id): - # Retrieve the enrollment for the current user - enrollment = get_object_or_404(Enrollment, id=enrollment_id, student=request.user) - # Ensure the course is completed before generating a certificate - if enrollment.status != "completed": - messages.error(request, "You can only generate a certificate for a completed course.") - return redirect("student_dashboard") - - # Check if a certificate already exists for this course and user - certificate = Certificate.objects.filter(user=request.user, course=enrollment.course).first() - if certificate: - messages.info(request, "Certificate already generated.") - return redirect("certificate_detail", certificate_id=certificate.certificate_id) - - # Create a new certificate record manually - certificate = Certificate.objects.create(user=request.user, course=enrollment.course) - messages.success(request, "Certificate generated successfully!") - return redirect("certificate_detail", certificate_id=certificate.certificate_id) - - -@login_required -def tracker_list(request): - trackers = ProgressTracker.objects.filter(user=request.user).order_by("-updated_at") - return render(request, "trackers/list.html", {"trackers": trackers}) - - -@login_required -def create_tracker(request): - if request.method == "POST": - form = ProgressTrackerForm(request.POST) - if form.is_valid(): - tracker = form.save(commit=False) - tracker.user = request.user - tracker.save() - return redirect("tracker_detail", tracker_id=tracker.id) - else: - form = ProgressTrackerForm() - return render(request, "trackers/form.html", {"form": form, "title": "Create Progress Tracker"}) - - -@login_required -def update_tracker(request, tracker_id): - tracker = get_object_or_404(ProgressTracker, id=tracker_id, user=request.user) - - if request.method == "POST": - form = ProgressTrackerForm(request.POST, instance=tracker) - if form.is_valid(): - form.save() - return redirect("tracker_detail", tracker_id=tracker.id) - else: - form = ProgressTrackerForm(instance=tracker) - return render(request, "trackers/form.html", {"form": form, "tracker": tracker, "title": "Update Progress Tracker"}) - - -@login_required -def tracker_detail(request, tracker_id): - tracker = get_object_or_404(ProgressTracker, id=tracker_id, user=request.user) - embed_url = request.build_absolute_uri(f"/trackers/embed/{tracker.embed_code}/") - return render(request, "trackers/detail.html", {"tracker": tracker, "embed_url": embed_url}) - - -@login_required -def update_progress(request, tracker_id): - if request.method == "POST" and request.headers.get("X-Requested-With") == "XMLHttpRequest": - tracker = get_object_or_404(ProgressTracker, id=tracker_id, user=request.user) - - try: - new_value = int(request.POST.get("current_value", tracker.current_value)) - tracker.current_value = new_value - tracker.save() - - return JsonResponse( - {"success": True, "percentage": tracker.percentage, "current_value": tracker.current_value} - ) - except ValueError: - return JsonResponse({"success": False, "error": "Invalid value"}, status=400) - return JsonResponse({"success": False, "error": "Invalid request"}, status=400) - - -@xframe_options_exempt -def embed_tracker(request, embed_code): - tracker = get_object_or_404(ProgressTracker, embed_code=embed_code, public=True) - return render(request, "trackers/embed.html", {"tracker": tracker}) - - -@login_required -def streak_detail(request): - """Display the user's learning streak.""" - if not request.user.is_authenticated: - return redirect("account_login") - streak, created = LearningStreak.objects.get_or_create(user=request.user) - return render(request, "streak_detail.html", {"streak": streak}) - - -@login_required -def waiting_room_list(request): - """View for displaying waiting rooms categorized by status.""" - # Get waiting rooms by status - open_rooms = WaitingRoom.objects.filter(status="open") - fulfilled_rooms = WaitingRoom.objects.filter(status="fulfilled") - closed_rooms = WaitingRoom.objects.filter(status="closed") - - # Get waiting rooms created by the user - user_created_rooms = WaitingRoom.objects.filter(creator=request.user) - - # Get waiting rooms joined by the user - user_joined_rooms = request.user.joined_waiting_rooms.all() - - # Process topics for all waiting rooms - all_rooms = ( - list(open_rooms) - + list(fulfilled_rooms) - + list(closed_rooms) - + list(user_created_rooms) - + list(user_joined_rooms) - ) - room_topics = {} - for room in all_rooms: - room_topics[room.id] = [topic.strip() for topic in room.topics.split(",") if topic.strip()] - - context = { - "open_rooms": open_rooms, - "fulfilled_rooms": fulfilled_rooms, - "closed_rooms": closed_rooms, - "user_created_rooms": user_created_rooms, - "user_joined_rooms": user_joined_rooms, - "room_topics": room_topics, - } - return render(request, "waiting_room/list.html", context) - - -def find_matching_courses(waiting_room): - """Find courses that match the waiting room's subject and topics.""" - # Get courses with matching subject name (case-insensitive) - matching_courses = Course.objects.filter(subject__iexact=waiting_room.subject, status="published") - - # Filter courses that have all required topics - required_topics = {t.strip().lower() for t in waiting_room.topics.split(",") if t.strip()} - - # Further filter courses by checking if their topics contain all required topics - final_matches = [] - for course in matching_courses: - course_topics = {t.strip().lower() for t in course.topics.split(",") if t.strip()} - if course_topics.issuperset(required_topics): - final_matches.append(course) - - return final_matches - - -def waiting_room_detail(request, waiting_room_id): - """View for displaying details of a waiting room.""" - waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) - - # Check if the user is a participant - is_participant = request.user.is_authenticated and request.user in waiting_room.participants.all() - - # Check if the user is the creator - is_creator = request.user.is_authenticated and request.user == waiting_room.creator - - # Check if the user is a teacher - is_teacher = request.user.is_authenticated and hasattr(request.user, "profile") and request.user.profile.is_teacher - - context = { - "waiting_room": waiting_room, - "is_participant": is_participant, - "is_creator": is_creator, - "is_teacher": is_teacher, - "participant_count": waiting_room.participants.count() + 1, # Add 1 to include the creator - "topic_list": [topic.strip() for topic in waiting_room.topics.split(",") if topic.strip()], - } - return render(request, "waiting_room/detail.html", context) - - -@login_required -def join_waiting_room(request, waiting_room_id): - """View for joining a waiting room.""" - waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) - - # Check if the waiting room is open - if waiting_room.status != "open": - messages.error(request, "This waiting room is no longer open for joining.") - return redirect("waiting_room_list") - - # Add the user as a participant if not already - if request.user not in waiting_room.participants.all(): - waiting_room.participants.add(request.user) - messages.success(request, f"You have joined the waiting room: {waiting_room.title}") - else: - messages.info(request, "You are already a participant in this waiting room.") - - return redirect("waiting_room_detail", waiting_room_id=waiting_room.id) - - -@login_required -def leave_waiting_room(request, waiting_room_id): - """View for leaving a waiting room.""" - waiting_room = get_object_or_404(WaitingRoom, id=waiting_room_id) - - # Remove the user from participants - if request.user in waiting_room.participants.all(): - waiting_room.participants.remove(request.user) - messages.success(request, f"You have left the waiting room: {waiting_room.title}") - else: - messages.info(request, "You are not a participant in this waiting room.") - - return redirect("waiting_room_list") - - -def is_superuser(user): - return user.is_superuser - - -@user_passes_test(is_superuser) -def sync_github_milestones(request): - """Sync GitHub milestones with forum topics.""" - github_repo = "alphaonelabs/alphaonelabs-education-website" - milestones_url = f"https://api.github.com/repos/{github_repo}/milestones" - - try: - # Get GitHub milestones - response = requests.get(milestones_url) - response.raise_for_status() - milestones = response.json() - - # Get or create a forum category for milestones - category, created = ForumCategory.objects.get_or_create( - name="GitHub Milestones", - defaults={ - "slug": "github-milestones", - "description": "Discussions about GitHub milestones and project roadmap", - "icon": "fa-github", - }, - ) - - # Count for tracking - created_count = 0 - updated_count = 0 - - for milestone in milestones: - milestone_title = milestone["title"] - milestone_description = milestone["description"] or "No description provided." - milestone_url = milestone["html_url"] - milestone_state = milestone["state"] - open_issues = milestone["open_issues"] - closed_issues = milestone["closed_issues"] - due_date = milestone.get("due_on", "No due date") - - # Format content with progress information - progress = 0 - if open_issues + closed_issues > 0: - progress = (closed_issues / (open_issues + closed_issues)) * 100 - - content = f""" -## Milestone: {milestone_title} - -{milestone_description} - -**State:** {milestone_state} -**Progress:** {progress:.1f}% ({closed_issues} closed / {open_issues} open issues) -**Due Date:** {due_date} - -[View on GitHub]({milestone_url}) - """ - - # Try to find an existing topic for this milestone - topic = ForumTopic.objects.filter( - category=category, title__startswith=f"Milestone: {milestone_title}" - ).first() - - if topic: - # Update existing topic - topic.content = content - topic.is_pinned = milestone_state == "open" # Pin open milestones - topic.save() - updated_count += 1 - else: - # Create new topic - # Use the first superuser as the author - author = User.objects.filter(is_superuser=True).first() - if author: - ForumTopic.objects.create( - category=category, - title=f"Milestone: {milestone_title}", - content=content, - author=author, - is_pinned=(milestone_state == "open"), - ) - created_count += 1 - - if created_count or updated_count: - messages.success( - request, f"Successfully synced GitHub milestones: {created_count} created, {updated_count} updated." - ) - else: - messages.info(request, "No GitHub milestones to sync.") - - except requests.exceptions.RequestException as e: - messages.error(request, f"Error fetching GitHub milestones: {str(e)}") - except Exception as e: - messages.error(request, f"Error syncing milestones: {str(e)}") - - return redirect("forum_categories") - - -@login_required -def toggle_course_status(request, slug): - """Toggle a course between draft and published status""" - course = get_object_or_404(Course, slug=slug) - - # Check if user is the course teacher - if request.user != course.teacher: - messages.error(request, "Only the course teacher can modify course status!") - return redirect("course_detail", slug=slug) - - # Toggle the status between draft and published - if course.status == "draft": - course.status = "published" - messages.success(request, "Course has been published successfully!") - elif course.status == "published": - course.status = "draft" - messages.success(request, "Course has been unpublished and is now in draft mode.") - # Note: We don't toggle from/to 'archived' status as that's a separate action - - course.save() - return redirect("course_detail", slug=slug) - - -def public_profile(request, username): - user = get_object_or_404(User, username=username) - - try: - profile = user.profile - except Profile.DoesNotExist: - # Instead of raising Http404, we call custom_404. - return custom_404(request, "Profile not found.") - - if not profile.is_profile_public: - return custom_404(request, "Profile not found.") - - context = {"profile": profile} - - if profile.is_teacher: - courses = Course.objects.filter(teacher=user) - total_students = sum(course.enrollments.filter(status="approved").count() for course in courses) - context.update( - { - "teacher_stats": { - "courses": courses, - "total_courses": courses.count(), - "total_students": total_students, - } - } - ) - else: - enrollments = Enrollment.objects.filter(student=user) - completed_enrollments = enrollments.filter(status="completed") - total_courses = enrollments.count() - total_completed = completed_enrollments.count() - total_progress = 0 - progress_count = 0 - for enrollment in enrollments: - progress, _ = CourseProgress.objects.get_or_create(enrollment=enrollment) - total_progress += progress.completion_percentage - progress_count += 1 - avg_progress = round(total_progress / progress_count) if progress_count > 0 else 0 - context.update( - { - "total_courses": total_courses, - "total_completed": total_completed, - "avg_progress": avg_progress, - "completed_courses": completed_enrollments, - } - ) - - return render(request, "public_profile_detail.html", context) - - -class GradeableLinkListView(ListView): - """View to display all submitted links that can be graded.""" - - model = GradeableLink - template_name = "grade_links/link_list.html" - context_object_name = "links" - paginate_by = 10 - - -class GradeableLinkDetailView(DetailView): - """View to display details about a specific link and its grades.""" - - model = GradeableLink - template_name = "grade_links/link_detail.html" - context_object_name = "link" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # Check if user is authenticated - if self.request.user.is_authenticated: - # Check if the user has already graded this link - try: - user_grade = LinkGrade.objects.get(link=self.object, user=self.request.user) - context["user_grade"] = user_grade - context["grade_form"] = LinkGradeForm(instance=user_grade) - except LinkGrade.DoesNotExist: - context["grade_form"] = LinkGradeForm() - - # Get all grades for this link - context["grades"] = self.object.grades.all() - - return context - - -class GradeableLinkCreateView(LoginRequiredMixin, CreateView): - """View to submit a new link for grading.""" - - model = GradeableLink - form_class = GradeableLinkForm - template_name = "grade_links/submit_link.html" - success_url = reverse_lazy("gradeable_link_list") - - def form_valid(self, form): - form.instance.user = self.request.user - messages.success(self.request, "Your link has been submitted for grading!") - return super().form_valid(form) - - -@login_required -def grade_link(request, pk): - """View to grade a link.""" - link = get_object_or_404(GradeableLink, pk=pk) - - # Prevent users from grading their own links - if link.user == request.user: - messages.error(request, "You cannot grade your own submissions!") - return redirect("gradeable_link_detail", pk=link.pk) - - # Check if the user has already graded this link - try: - user_grade = LinkGrade.objects.get(link=link, user=request.user) - except LinkGrade.DoesNotExist: - user_grade = None - - if request.method == "POST": - form = LinkGradeForm(request.POST, instance=user_grade) - if form.is_valid(): - grade = form.save(commit=False) - grade.link = link - grade.user = request.user - grade.save() - messages.success(request, "Your grade has been submitted!") - return redirect("gradeable_link_detail", pk=link.pk) - else: - form = LinkGradeForm(instance=user_grade) - - return render( - request, - "grade_links/grade_link.html", - { - "form": form, - "link": link, - }, - ) - - -def duplicate_session(request, session_id): - """Duplicate a session to next week.""" - # Get the original session - session = get_object_or_404(Session, id=session_id) - course = session.course - - # Check if user is the course teacher - if request.user != course.teacher: - messages.error(request, "Only the course teacher can duplicate sessions!") - return redirect("course_detail", slug=course.slug) - - # Create a new session with the same properties but dates shifted forward by a week - new_session = Session( - course=course, - title=session.title, - description=session.description, - is_virtual=session.is_virtual, - meeting_link=session.meeting_link, - meeting_id="", # Clear meeting ID as it will be a new meeting - location=session.location, - price=session.price, - enable_rollover=session.enable_rollover, - rollover_pattern=session.rollover_pattern, - ) - - # Set dates one week later - time_shift = timezone.timedelta(days=7) - new_session.start_time = session.start_time + time_shift - new_session.end_time = session.end_time + time_shift - - # Save the new session - new_session.save() - msg = f"Session '{session.title}' duplicated for {new_session.start_time.strftime('%b %d, %Y')}" - messages.success(request, msg) - - return redirect("course_detail", slug=course.slug) - - -def run_create_test_data(request): - """Run the create_test_data management command and redirect to homepage.""" - from django.conf import settings - - if not settings.DEBUG: - messages.error(request, "This action is only available in debug mode.") - return redirect("index") - - try: - call_command("create_test_data") - messages.success(request, "Test data has been created successfully!") - except Exception as e: - messages.error(request, f"Error creating test data: {str(e)}") - - return redirect("index") - - -@login_required -@require_POST -def teacher_update_student_attendance(request, classroom_id): - """Handle student attendance marking.""" - try: - classroom = VirtualClassroom.objects.get(id=classroom_id) - student = request.user # Student marks their own attendance - - # Check if student is enrolled - is_enrolled = False - if classroom.course: - is_enrolled = classroom.course.enrollments.filter(student=student, status="approved").exists() - else: - # For classrooms without a course, check VirtualClassroomParticipant table - is_enrolled = VirtualClassroomParticipant.objects.filter(classroom=classroom, user=student).exists() - - if not is_enrolled: - return JsonResponse({"success": False, "message": "You are not enrolled in this class"}, status=403) - - # Get or create today's session - today = timezone.now().date() - today_start = timezone.make_aware(datetime.combine(today, datetime.min.time())) - today_end = timezone.make_aware(datetime.combine(today, datetime.max.time())) - - if classroom.course: - session = Session.objects.filter( - course=classroom.course, - start_time__range=(today_start, today_end), - ).first() - if not session: - session = Session.objects.create( - course=classroom.course, - title=f"Class on {today.strftime('%Y-%m-%d')}", - start_time=today_start, - end_time=today_end, - ) - else: - session = Session.objects.filter( - title=f"Class on {today.strftime('%Y-%m-%d')}", - start_time__range=(today_start, today_end), - course__isnull=True, - ).first() - if not session: - session = Session.objects.create( - title=f"Class on {today.strftime('%Y-%m-%d')}", - start_time=today_start, - end_time=today_end, - course=None, - ) - - # Mark attendance - attendance, created = SessionAttendance.objects.get_or_create( - session=session, student=student, defaults={"status": "present"} - ) - - if not created: - # If attendance record already exists, update its status to present - attendance.status = "present" - attendance.save() - - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({"success": True, "message": "Attendance updated successfully", "created": created}) - else: - messages.success(request, "Your attendance has been marked.") - return redirect("classroom_attendance", classroom_id=classroom_id) - - except VirtualClassroom.DoesNotExist: - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({"success": False, "message": "Classroom not found"}, status=404) - else: - messages.error(request, "Classroom not found.") - return redirect("virtual_classroom_list") - except Exception as e: - logger.exception("Error updating student attendance: %s", str(e)) - if request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({"success": False, "message": "An internal error occurred"}, status=500) - else: - messages.error(request, "An internal error occurred.") - return redirect("classroom_attendance", classroom_id=classroom_id) - - -@login_required -@teacher_required -def student_management(request, course_slug, student_id): - """ - View for managing a specific student in a course. - This replaces the modal functionality with a dedicated page. - """ - course = get_object_or_404(Course, slug=course_slug) - student = get_object_or_404(User, id=student_id) - - # Check if user is the course teacher - if request.user != course.teacher: - messages.error(request, "Only the course teacher can manage students!") - return redirect("course_detail", slug=course.slug) - - # Check if student is enrolled in this course - enrollment = get_object_or_404(Enrollment, course=course, student=student) - - # Get sessions for this course - sessions = course.sessions.all().order_by("start_time") - - # Get attendance records - attendance_records = SessionAttendance.objects.filter(student=student, session__course=course).select_related( - "session" - ) - - # Format attendance data for easier access in template - attendance_data = {} - for record in attendance_records: - attendance_data[record.session.id] = {"status": record.status, "notes": record.notes} - - # Get student progress data - progress = CourseProgress.objects.filter(enrollment=enrollment).first() - completed_sessions = [] - if progress: - completed_sessions = progress.completed_sessions.all() - - # Calculate attendance rate - total_sessions = sessions.count() - attended_sessions = SessionAttendance.objects.filter( - student=student, session__course=course, status__in=["present", "late"] - ).count() - - attendance_rate = 0 - if total_sessions > 0: - attendance_rate = int((attended_sessions / total_sessions) * 100) - - # Get badges earned by this student - user_badges = student.badges.all() - - context = { - "course": course, - "student": student, - "enrollment": enrollment, - "sessions": sessions, - "attendance_data": attendance_data, - "attendance_rate": attendance_rate, - "progress": progress, - "completed_sessions": completed_sessions, - "badges": user_badges, - } - - return render(request, "courses/student_management.html", context) - - -@login_required -@teacher_required -def update_student_progress(request, enrollment_id): - """ - View for updating a student's progress in a course. - """ - enrollment = get_object_or_404(Enrollment, id=enrollment_id) - course = enrollment.course - - # Check if user is the course teacher - if request.user != course.teacher: - messages.error(request, "Only the course teacher can update student progress!") - return redirect("course_detail", slug=course.slug) - - if request.method == "POST": - grade = request.POST.get("grade") - status = request.POST.get("status") - comments = request.POST.get("comments", "") - - # Update enrollment - enrollment.grade = grade - enrollment.status = status - enrollment.notes = comments - enrollment.last_grade_update = timezone.now() - enrollment.save() - - messages.success(request, f"Progress for {enrollment.student.username} updated successfully!") - return redirect("student_management", course_slug=course.slug, student_id=enrollment.student.id) - - # If not POST, redirect back to student management - return redirect("student_management", course_slug=course.slug, student_id=enrollment.student.id) - - -@login_required -@teacher_required -def update_teacher_notes(request, enrollment_id): - """ - View for updating teacher's private notes for a student. - """ - enrollment = get_object_or_404(Enrollment, id=enrollment_id) - course = enrollment.course - - # Check if user is the course teacher - if request.user != course.teacher: - messages.error(request, "Only the course teacher can update notes!") - return redirect("course_detail", slug=course.slug) - - if request.method == "POST": - notes = request.POST.get("teacher_notes", "") - - # If notes have changed, create a new note history entry - if enrollment.teacher_notes != notes and notes.strip(): - NoteHistory.objects.create(enrollment=enrollment, content=notes, created_by=request.user) - - # Update enrollment - enrollment.teacher_notes = notes - enrollment.save() - - messages.success(request, f"Notes for {enrollment.student.username} updated successfully!") - - return redirect("student_management", course_slug=course.slug, student_id=enrollment.student.id) - - -@login_required -@teacher_required -@require_POST -def update_session_attendance(request): - """Update student attendance for a specific session.""" - try: - # Get form data - session_id = request.POST.get("session_id") - student_id = request.POST.get("student_id") - status = request.POST.get("status") - notes = request.POST.get("notes", "") - - if not session_id or not student_id or not status: - return JsonResponse({"success": False, "message": "Missing required data"}, status=400) - - # Get objects - session = get_object_or_404(Session, id=session_id) - student = get_object_or_404(User, id=student_id) - - # Check if the current user is the teacher for this session's course - if session.course and session.course.teacher != request.user: - return JsonResponse( - {"success": False, "message": "Only the course teacher can update attendance"}, status=403 - ) - - # Update or create attendance record - attendance, created = SessionAttendance.objects.get_or_create( - session=session, student=student, defaults={"status": status, "notes": notes} - ) - - if not created: - attendance.status = status - attendance.notes = notes - attendance.save() - - return JsonResponse({"success": True, "message": "Attendance updated successfully", "created": created}) - - except Exception as e: - # Log the detailed exception for debugging - logger.exception("Error in update_session_attendance: %s", str(e)) - return JsonResponse({"success": False, "message": "An internal error occurred"}, status=500) - - -@login_required -@teacher_required -@require_POST -def award_badge(request): - """ - AJAX view for awarding badges to students. - """ - if not request.headers.get("X-Requested-With") == "XMLHttpRequest": - return JsonResponse({"success": False, "message": "Invalid request"}, status=400) - - student_id = request.POST.get("student_id") - badge_type = request.POST.get("badge_type") - course_slug = request.POST.get("course_slug") - - if not all([student_id, badge_type, course_slug]): - return JsonResponse({"success": False, "message": "Missing required parameters"}, status=400) - - try: - student = User.objects.get(id=student_id) - course = Course.objects.get(slug=course_slug) - - # Check if user is the course teacher - if request.user != course.teacher: - return JsonResponse( - {"success": False, "message": "Unauthorized: Only the course teacher can award badges"}, status=403 - ) - - # Handle different badge types - badge = None - if badge_type == "perfect_attendance": - badge, created = Badge.objects.get_or_create( - name="Perfect Attendance", - defaults={"description": "Awarded for attending all sessions in a course", "points": 50}, - ) - elif badge_type == "participation": - badge, created = Badge.objects.get_or_create( - name="Outstanding Participation", - defaults={"description": "Awarded for exceptional participation in course discussions", "points": 75}, - ) - elif badge_type == "completion": - badge, created = Badge.objects.get_or_create( - name="Course Completion", - defaults={"description": "Awarded for successfully completing the course", "points": 100}, - ) - else: - return JsonResponse({"success": False, "message": "Invalid badge type"}, status=400) - - # Award the badge to the student - user_badge, created = UserBadge.objects.get_or_create( - user=student, badge=badge, defaults={"awarded_by": request.user, "course": course} - ) - - if not created: - return JsonResponse({"success": False, "message": "Student already has this badge"}, status=400) - - return JsonResponse( - {"success": True, "message": f"Badge '{badge.name}' awarded successfully to {student.username}"} - ) - - except User.DoesNotExist: - return JsonResponse({"success": False, "message": "Student not found"}, status=404) - except Course.DoesNotExist: - return JsonResponse({"success": False, "message": "Course not found"}, status=404) - except Exception as e: - logger.exception("Error awarding badge: %s", str(e)) - return JsonResponse({"success": False, "message": "An internal error occurred"}, status=500) - - -def notification_preferences(request): - """ - Display and update the notification preferences for the logged-in user. - """ - # Get (or create) the user's notification preferences. - preference, created = NotificationPreference.objects.get_or_create(user=request.user) - - if request.method == "POST": - form = NotificationPreferencesForm(request.POST, instance=preference) - if form.is_valid(): - form.save() - messages.success(request, "Your notification preferences have been updated.") - # Redirect to the profile page after saving - return redirect("profile") - else: - messages.error(request, "There was an error updating your preferences.") - else: - form = NotificationPreferencesForm(instance=preference) - - return render(request, "account/notification_preferences.html", {"form": form}) - - -@login_required -def invite_to_study_group(request, group_id): - """Invite a user to a study group.""" - group = get_object_or_404(StudyGroup, id=group_id) - - # Only allow invitations from current group members. - if request.user not in group.members.all(): - messages.error(request, "You must be a member of the group to invite others.") - return redirect("study_group_detail", group_id=group.id) - - if request.method == "POST": - email_or_username = request.POST.get("email_or_username") - # Search by email or username. - recipient = User.objects.filter(Q(email=email_or_username) | Q(username=email_or_username)).first() - if not recipient: - messages.error(request, f"No user found with email or username: {email_or_username}") - return redirect("study_group_detail", group_id=group.id) - - # Prevent duplicate invitations or inviting existing members. - if recipient in group.members.all(): - messages.warning(request, f"{recipient.username} is already a member of this group.") - return redirect("study_group_detail", group_id=group.id) - - if StudyGroupInvite.objects.filter(group=group, recipient=recipient, status="pending").exists(): - messages.warning(request, f"An invitation has already been sent to {recipient.username}.") - return redirect("study_group_detail", group_id=group.id) - - if group.is_full(): - messages.error(request, "The study group is full. No new members can be added.") - return redirect("study_group_detail", group_id=group.id) - - # Create a notification for the recipient. - notification_url = request.build_absolute_uri(reverse("user_invitations")) - notification_text = ( - f"{request.user.username} has invited you to join the study group: {group.name}. " - f"View invitations here: {notification_url}" - ) - Notification.objects.create( - user=recipient, title="Study Group Invitation", message=notification_text, notification_type="info" - ) - - messages.success(request, f"Invitation sent to {recipient.username}.") - return redirect("study_group_detail", group_id=group.id) - - return redirect("study_group_detail", group_id=group.id) - - -@login_required -def user_invitations(request): - """Display pending study group invitations for the user.""" - invitations = StudyGroupInvite.objects.filter(recipient=request.user, status="pending").select_related( - "group", "sender" - ) - return render(request, "web/study/invitations.html", {"invitations": invitations}) - - -@login_required -def respond_to_invitation(request, invite_id): - """Accept or decline a study group invitation.""" - invite = get_object_or_404(StudyGroupInvite, id=invite_id, recipient=request.user) - if request.method == "POST": - response = request.POST.get("response") - if response == "accept": - if invite.group.is_full(): - messages.error(request, "The study group is full. Cannot join.") - return redirect("user_invitations") - invite.accept() - study_group_url = request.build_absolute_uri(reverse("study_group_detail", args=[invite.group.id])) - notification_text = f"{request.user.username} has accepted your invitation to join {invite.group.name}.\ - View group details here: {study_group_url}" - Notification.objects.create( - user=invite.sender, title="Invitation Accepted", message=notification_text, notification_type="success" - ) - messages.success(request, f"You have joined {invite.group.name}.") - return redirect("user_invitations") - elif response == "decline": - invite.decline() - study_group_url = request.build_absolute_uri(reverse("study_group_detail", args=[invite.group.id])) - notification_text = f"{request.user.username} has declined your invitation to join {invite.group.name}.\ - View group details here: {study_group_url}" - Notification.objects.create( - user=invite.sender, title="Invitation Declined", message=notification_text, notification_type="warning" - ) - messages.info(request, f"You have declined the invitation to {invite.group.name}.") - return redirect("user_invitations") - - return redirect("user_invitations") - - -@login_required -def create_study_group(request): - if request.method == "POST": - form = StudyGroupForm(request.POST) - if form.is_valid(): - study_group = form.save(commit=False) - study_group.creator = request.user - study_group.save() - # Automatically add the creator as a member - study_group.members.add(request.user) - messages.success(request, "Study group created successfully!") - return redirect("study_group_detail", group_id=study_group.id) - else: - form = StudyGroupForm() - return render(request, "web/study/create_group.html", {"form": form}) - - -def features_page(request): - """View to display the features page.""" - return render(request, "features.html") - - -@require_POST -@csrf_protect -def feature_vote(request): - """API endpoint to handle feature voting.""" - feature_id = request.POST.get("feature_id") - vote_type = request.POST.get("vote") - - if not feature_id or vote_type not in ["up", "down"]: - return JsonResponse({"status": "error", "message": "Invalid parameters"}, status=400) - - # Store IP for anonymous users - ip_address = None - if not request.user.is_authenticated: - ip_address = ( - request.META.get("REMOTE_ADDR") or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() - ) - if not ip_address: - return JsonResponse({"status": "error", "message": "Could not identify user"}, status=400) - - # Process the vote - try: - # Use transaction to prevent race conditions during vote operations - with transaction.atomic(): - # Check for existing vote - existing_vote = None - if request.user.is_authenticated: - existing_vote = FeatureVote.objects.filter(feature_id=feature_id, user=request.user).first() - elif ip_address: - existing_vote = FeatureVote.objects.filter( - feature_id=feature_id, ip_address=ip_address, user__isnull=True - ).first() - - # Handle the vote logic - status_message = "Vote recorded" - if existing_vote: - if existing_vote.vote == vote_type: - status_message = "You've already cast this vote" - else: - # Change vote type if user changed their mind - existing_vote.vote = vote_type - existing_vote.save() - status_message = "Vote changed" - else: - # Create new vote - new_vote = FeatureVote(feature_id=feature_id, vote=vote_type) - - if request.user.is_authenticated: - new_vote.user = request.user - else: - new_vote.ip_address = ip_address - - new_vote.save() - - # Get updated counts within the transaction to ensure consistency - up_count = FeatureVote.objects.filter(feature_id=feature_id, vote="up").count() - down_count = FeatureVote.objects.filter(feature_id=feature_id, vote="down").count() - - # Calculate percentage for visualization - total_votes = up_count + down_count - up_percentage = round((up_count / total_votes) * 100) if total_votes > 0 else 0 - down_percentage = round((down_count / total_votes) * 100) if total_votes > 0 else 0 - - return JsonResponse( - { - "status": "success", - "message": status_message, - "up_count": up_count, - "down_count": down_count, - "total_votes": total_votes, - "up_percentage": up_percentage, - "down_percentage": down_percentage, - } - ) - - except Exception as e: - logger.exception("Error processing vote: %s", str(e)) - return JsonResponse({"status": "error", "message": "An internal error occurred"}, status=500) - - -@require_GET -def feature_vote_count(request): - """Get vote counts for one or more features.""" - feature_ids = request.GET.get("feature_ids", "").split(",") - if not feature_ids or not feature_ids[0]: - return JsonResponse({"error": "No feature IDs provided"}, status=400) - - result = {} - # Get all up and down votes in a single query each for better performance - up_votes = ( - FeatureVote.objects.filter(feature_id__in=feature_ids, vote="up") - .values("feature_id") - .annotate(count=Count("id")) - ) - down_votes = ( - FeatureVote.objects.filter(feature_id__in=feature_ids, vote="down") - .values("feature_id") - .annotate(count=Count("id")) - ) - - # Create lookup dictionaries for efficient access - up_votes_dict = {str(vote["feature_id"]): vote["count"] for vote in up_votes} - down_votes_dict = {str(vote["feature_id"]): vote["count"] for vote in down_votes} - - # Check user's votes if authenticated - user_votes = {} - if request.user.is_authenticated: - user_vote_objects = FeatureVote.objects.filter(feature_id__in=feature_ids, user=request.user) - for vote in user_vote_objects: - user_votes[str(vote.feature_id)] = vote.vote - elif request.META.get("REMOTE_ADDR"): - # Get IP address for anonymous users - ip_address = ( - request.META.get("REMOTE_ADDR") or request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() - ) - if ip_address: - anon_vote_objects = FeatureVote.objects.filter( - feature_id__in=feature_ids, ip_address=ip_address, user__isnull=True - ) - for vote in anon_vote_objects: - user_votes[str(vote.feature_id)] = vote.vote - - for feature_id in feature_ids: - up_count = up_votes_dict.get(feature_id, 0) - down_count = down_votes_dict.get(feature_id, 0) - total_votes = up_count + down_count - - # Calculate percentages - up_percentage = (up_count / total_votes * 100) if total_votes > 0 else 0 - down_percentage = (down_count / total_votes * 100) if total_votes > 0 else 0 - - result[feature_id] = { - "up_count": up_count, - "down_count": down_count, - "total_votes": total_votes, - "up_percentage": round(up_percentage, 1), - "down_percentage": round(down_percentage, 1), - "user_vote": user_votes.get(feature_id, None), - } - - return JsonResponse(result) - - -@login_required -def progress_visualization(request): - """Generate and render progress visualization statistics for a student's enrolled courses.""" - user = request.user - - # Create a unique cache key based on user ID - cache_key = f"user_progress_{user.id}" - context = cache.get(cache_key) - - if not context: - # Cache miss - calculate all data - enrollments = Enrollment.objects.filter(student=user) - course_stats = calculate_course_stats(enrollments) - attendance_stats = calculate_attendance_stats(user, enrollments) - learning_activity = calculate_learning_activity(user, enrollments) - completion_pace = calculate_completion_pace(enrollments) - chart_data = prepare_chart_data(enrollments) - - # Combine all stats into a single context dictionary - context = {**course_stats, **attendance_stats, **learning_activity, **completion_pace, **chart_data} - # Cache the results (no expiration, we'll invalidate manually via signals) - cache.set(cache_key, context, timeout=None) # None means no expiration - - return render(request, "courses/progress_visualization.html", context) - - -def calculate_course_stats(enrollments): - """Calculate statistics on the user's course progress.""" - total_courses = enrollments.count() - courses_completed = enrollments.filter(status="completed").count() - topics_mastered = sum(e.progress.completed_sessions.count() for e in enrollments if hasattr(e, "progress")) - - return { - "total_courses": total_courses, - "courses_completed": courses_completed, - "courses_completed_percentage": round((courses_completed / total_courses) * 100) if total_courses else 0, - "topics_mastered": topics_mastered, - } - - -def calculate_attendance_stats(user, enrollments): - """Calculate the user's attendance statistics.""" - all_attendances = SessionAttendance.objects.filter( - student=user, session__course__in=[e.course for e in enrollments] - ) - total_attendance_count = all_attendances.count() - present_attendance_count = all_attendances.filter(status__in=["present", "late"]).count() - - return { - "average_attendance": ( - round((present_attendance_count / total_attendance_count) * 100) if total_attendance_count else 0 - ) - } - - -def calculate_learning_activity(user, enrollments): - """Calculate learning activity metrics like active days, streaks, and learning hours.""" - all_completed_sessions = [ - s for s in get_all_completed_sessions(enrollments) if s.start_time and s.end_time and s.end_time > s.start_time - ] - now = timezone.now() - - # Find the most active day of the week - most_active_day = Counter(session.start_time.strftime("%A") for session in all_completed_sessions).most_common(1) - # Find the most recent session date - last_session_date = ( - max(all_completed_sessions, key=lambda s: s.start_time).start_time.strftime("%b %d, %Y") - if all_completed_sessions - else "N/A" - ) - - streak, _ = LearningStreak.objects.get_or_create(user=user) - current_streak = streak.current_streak - - total_learning_hours = round( - sum((s.end_time - s.start_time).total_seconds() / 3600 for s in all_completed_sessions), 1 - ) - - # Calculate the number of weeks since the first session, minimum 1 week - weeks_since_first_session = ( - max(1, (now - min(all_completed_sessions, key=lambda s: s.start_time).start_time).days / 7) - if all_completed_sessions - else 1 - ) - avg_sessions_per_week = round(len(all_completed_sessions) / weeks_since_first_session, 1) - - return { - # Extract the day name from the most common day tuple, or default to "N/A" - "most_active_day": most_active_day[0][0] if most_active_day else "N/A", - "last_session_date": last_session_date, - "current_streak": current_streak, - "total_learning_hours": total_learning_hours, - "avg_sessions_per_week": avg_sessions_per_week, - } - - -def calculate_completion_pace(enrollments): - """Calculate the average completion pace for completed courses.""" - completed_enrollments = enrollments.filter(status="completed") - if not completed_enrollments.exists(): - return {"completion_pace": "N/A"} - - total_days = sum( - (e.completion_date - e.enrollment_date).days - for e in completed_enrollments - if e.completion_date and e.enrollment_date - ) - avg_days_to_complete = total_days / completed_enrollments.count() if completed_enrollments.count() > 0 else 0 - - return {"completion_pace": f"{avg_days_to_complete:.0f} days/course"} - - -def get_all_completed_sessions(enrollments): - """Retrieve all completed sessions for a user's enrollments.""" - return [s for e in enrollments if hasattr(e, "progress") for s in e.progress.completed_sessions.all()] - - -def prepare_chart_data(enrollments): - """Prepare data for visualizing user progress in charts.""" - colors = ["255,99,132", "54,162,235", "255,206,86", "75,192,192", "153,102,255"] - - courses = [] - progress_dates, sessions_completed = [], [] - - for i, e in enumerate(enrollments): - color = colors[i % len(colors)] - # Basic course data with progress information - course_data = { - "title": e.course.title, - "color": color, - "progress": getattr(e.progress, "completion_percentage", 0), - "sessions_completed": e.progress.completed_sessions.count() if hasattr(e, "progress") else 0, - "total_sessions": e.course.sessions.count(), - } - - # Add time series data for courses with completed sessions - if hasattr(e, "progress") and e.progress.completed_sessions.exists(): - # Find the most recent active session date - last_session = max(e.progress.completed_sessions.all(), key=lambda s: s.start_time) - course_data["last_active"] = last_session.start_time.strftime("%b %d, %Y") - # Generate time series data for progress visualization - time_data = prepare_time_series_data(e, course_data["total_sessions"]) - course_data.update(time_data) - progress_dates.append(time_data["dates"]) - sessions_completed.append(time_data["sessions_points"]) - else: - # Default values for courses without progress - course_data.update({"last_active": "Not started", "progress_over_time": []}) - progress_dates.append([]) - sessions_completed.append([]) - - courses.append(course_data) - - return { - "courses": courses, - # Create a sorted list of all unique dates by flattening and deduplicating - "progress_dates": json.dumps(sorted(set(date for dates in progress_dates for date in dates))), - "sessions_completed": json.dumps(sessions_completed), - "courses_json": json.dumps(courses), - } - - -def prepare_time_series_data(enrollment, total_sessions): - """Generate time series data for progress visualization.""" - completed_sessions = ( - sorted(enrollment.progress.completed_sessions.all(), key=lambda s: s.start_time) - if hasattr(enrollment, "progress") - else [] - ) - - return { - # Calculate progress percentage for each completed session - "progress_over_time": [ - round(((idx + 1) / total_sessions) * 100, 1) if total_sessions else 0 - for idx, _ in enumerate(completed_sessions) - ], - # Create sequential session numbers - "sessions_points": list(range(1, len(completed_sessions) + 1)), - # Format session dates consistently - "dates": [s.start_time.strftime("%Y-%m-%d") for s in completed_sessions], - } - - -# map views - - -@login_required -def classes_map(request): - """View for displaying classes near the user.""" - now = timezone.now() - sessions = ( - Session.objects.filter(Q(start_time__gte=now) | Q(start_time__lte=now, end_time__gte=now)) - .filter(is_virtual=False, location__isnull=False) - .exclude(location="") - .order_by("start_time") - .select_related("course", "course__teacher") - ) - # Get filter parameters - course_id = request.GET.get("course") - teaching_style = request.GET.get("teaching_style") - # Apply filters - if course_id: - sessions = sessions.filter(course_id=course_id, status="published") - if teaching_style: - sessions = sessions.filter(teaching_style=teaching_style) - # Fetch only necessary course fields - courses = Course.objects.only("id", "title").order_by("title") - age_groups = Course._meta.get_field("level").choices - teaching_styles = list(set(Session.objects.values_list("teaching_style", flat=True))) - context = {"sessions": sessions, "courses": courses, "age_groups": age_groups, "teaching_style": teaching_styles} - return render(request, "web/classes_map.html", context) - - -@login_required -def map_data_api(request): - """API to return all live and ongoing class data in JSON format.""" - now = timezone.now() - sessions = ( - Session.objects.filter( - Q(start_time__gte=now) | Q(start_time__lte=now, end_time__gte=now) # Future or Live classes - ) - .filter(Q(is_virtual=False) & ~Q(location="")) - .select_related("course", "course__teacher") - ) - - course_id = request.GET.get("course") - age_group = request.GET.get("age_group") - - if course_id: - sessions = sessions.filter(course__id=course_id, status="published") - if age_group: - sessions = sessions.filter(course__level=age_group) - - logger.debug(f"API call with filters: course={course_id}, age={age_group}") - - map_data = [] - sessions_to_update = [] - # Limit geocoding to a reasonable number per request - MAX_GEOCODING_PER_REQUEST = 5 - geocoding_count = 0 - geocoding_errors = 0 - coordinate_errors = 0 - for session in sessions: - if not session.latitude or not session.longitude: - if geocoding_count >= MAX_GEOCODING_PER_REQUEST: - logger.warning(f"Geocoding limit reached ({MAX_GEOCODING_PER_REQUEST}). Skipping session {session.id}") - continue - geocoding_count += 1 - logger.info(f"Geocoding session {session.id} with location: {session.location}") - lat, lng = geocode_address(session.location) - if lat is not None and lng is not None: - session.latitude = lat - session.longitude = lng - sessions_to_update.append(session) - else: - geocoding_errors += 1 - logger.warning(f"Skipping session {session.id} due to failed geocoding") - continue - - try: - lat = float(session.latitude) - lng = float(session.longitude) - map_data.append( - { - "id": session.id, - "title": session.title, - "course_title": session.course.title, - "teacher": session.course.teacher.get_full_name() or session.course.teacher.username, - "start_time": session.start_time.isoformat(), - "end_time": session.end_time.isoformat(), - "location": session.location, - "lat": lat, - "lng": lng, - "price": str(session.price or session.course.price), - "url": session.get_absolute_url(), - "course": session.course.title, - "level": session.course.get_level_display(), - "is_virtual": session.is_virtual, - } - ) - except (ValueError, TypeError): - coordinate_errors += 1 - logger.warning( - f"Skipping session {session.id} due to invalid coordinates: " - f"lat={session.latitude}, lng={session.longitude}" - ) - continue - - if sessions_to_update: - logger.info(f"Batch updating coordinates for {len(sessions_to_update)} sessions") - Session.objects.bulk_update(sessions_to_update, ["latitude", "longitude"]) # Batch update - - # Log summary of issues - if geocoding_errors > 0 or coordinate_errors > 0: - logger.warning(f"Map data issues: {geocoding_errors} geocoding errors, {coordinate_errors} coordinate errors") - - logger.info(f"Found {len(map_data)} sessions with valid coordinates") - return JsonResponse({"sessions": map_data}) - - -GITHUB_REPO = "alphaonelabs/alphaonelabs-education-website" -GITHUB_API_BASE = "https://api.github.com" - -logger = logging.getLogger(__name__) - - -def github_api_request(endpoint, params=None, headers=None): - """ - Make a GitHub API request with consistent error handling and timeout. - Returns JSON response on success, empty dict on failure. - """ - try: - response = requests.get(endpoint, params=params, headers=headers, timeout=10) - if response.status_code == 200: - return response.json() - else: - logger.error(f"Failed API request to {endpoint}: {response.status_code} - {response.text}") - return {} - except requests.RequestException as e: - logger.error(f"Request error for {endpoint}: {e}") - return {} - - -def get_user_contribution_metrics(username, token): - """ - Use the GitHub GraphQL API to fetch all of the user's merged PRs (across all repos), - then filter by the target repo, implementing pagination to ensure complete data. - """ - graphql_endpoint = "https://api.github.com/graphql" - headers = {"Authorization": f"Bearer {token}"} - - query = """ - query($username: String!, $after: String) { - user(login: $username) { - pullRequests( - first: 100 - after: $after - states: MERGED - orderBy: { field: CREATED_AT, direction: DESC } - ) { - totalCount - pageInfo { - endCursor - hasNextPage - } - nodes { - additions - deletions - createdAt - repository { - nameWithOwner - } - } - } - } - } - """ - - all_filtered_prs = [] - after_cursor = None - - while True: - variables = {"username": username, "after": after_cursor} - try: - response = requests.post( - graphql_endpoint, json={"query": query, "variables": variables}, headers=headers, timeout=15 - ) - if response.status_code == 200: - data = response.json() - if data.get("data") and data["data"].get("user"): - pr_data = data["data"]["user"]["pullRequests"] - prs = pr_data["nodes"] - - # Filter PRs to the target repository (case insensitive) - target_repo = GITHUB_REPO.lower() - filtered_prs = [pr for pr in prs if pr["repository"]["nameWithOwner"].lower() == target_repo] - all_filtered_prs.extend(filtered_prs) - - # Check if there are more pages - if pr_data["pageInfo"]["hasNextPage"]: - after_cursor = pr_data["pageInfo"]["endCursor"] - else: - break - else: - logger.error("No user or PR data found in GraphQL response.") - break - else: - logger.error(f"GraphQL error: {response.status_code} - {response.text}") - break - except Exception as e: - logger.error(f"GraphQL request error: {e}") - break - - # Prepare final result structure - final_result = { - "data": {"user": {"pullRequests": {"totalCount": len(all_filtered_prs), "nodes": all_filtered_prs}}} - } - return final_result - - -def contributor_detail_view(request, username): - """ - View to display detailed information about a specific GitHub contributor. - Only accessible to staff members. - """ - cache_key = f"github_contributor_{username}" - cached_data = cache.get(cache_key) - if cached_data: - return render(request, "web/contributor_detail.html", cached_data) - - token = os.environ.get("GITHUB_TOKEN") - headers = {"Authorization": f"token {token}"} if token else {} - - # Initialize variables to store contributor data - user_data = {} - prs_created = 0 - prs_merged = 0 - issues_created = 0 - pr_reviews = 0 - pr_comments = 0 - issue_comments = 0 - lines_added = 0 - lines_deleted = 0 - first_contribution_date = "N/A" - issue_assignments = 0 - - user_endpoint = f"{GITHUB_API_BASE}/users/{username}" - user_data = github_api_request(user_endpoint, headers=headers) - if not user_data: - logger.error("User profile data could not be retrieved.") - - # Pull requests created - prs_created_json = github_api_request( - f"{GITHUB_API_BASE}/search/issues", - params={"q": f"author:{username} type:pr repo:{GITHUB_REPO}"}, - headers=headers, - ) - prs_created = prs_created_json.get("total_count", 0) - - # Pull requests merged - prs_merged_json = github_api_request( - f"{GITHUB_API_BASE}/search/issues", - params={"q": f"author:{username} type:pr repo:{GITHUB_REPO} is:merged"}, - headers=headers, - ) - prs_merged = prs_merged_json.get("total_count", 0) - - # Issues created - issues_json = github_api_request( - f"{GITHUB_API_BASE}/search/issues", - params={"q": f"author:{username} type:issue repo:{GITHUB_REPO}"}, - headers=headers, - ) - issues_created = issues_json.get("total_count", 0) - - # Pull request reviews - reviews_json = github_api_request( - f"{GITHUB_API_BASE}/search/issues", - params={"q": f"reviewer:{username} type:pr repo:{GITHUB_REPO}"}, - headers=headers, - ) - pr_reviews = reviews_json.get("total_count", 0) - - # Pull requests with comments - pr_comments_json = github_api_request( - f"{GITHUB_API_BASE}/search/issues", - params={"q": f"commenter:{username} type:pr repo:{GITHUB_REPO}"}, - headers=headers, - ) - pr_comments = pr_comments_json.get("total_count", 0) - - # Issues with comments - issue_comments_json = github_api_request( - f"{GITHUB_API_BASE}/search/issues", - params={"q": f"commenter:{username} type:issue repo:{GITHUB_REPO}"}, - headers=headers, - ) - issue_comments = issue_comments_json.get("total_count", 0) - - # Oldest PR creation date - prs_oldest = github_api_request( - f"{GITHUB_API_BASE}/search/issues", - params={ - "q": f"author:{username} type:pr repo:{GITHUB_REPO}", - "sort": "created", - "order": "asc", - "per_page": 1, - }, - headers=headers, - ) - if prs_oldest.get("total_count", 0) > 0: - first_contribution_date = prs_oldest["items"][0].get("created_at", "N/A") - - # Issue assignments - issue_assignments_json = github_api_request( - f"{GITHUB_API_BASE}/search/issues", - params={"q": f"assignee:{username} type:issue repo:{GITHUB_REPO}"}, - headers=headers, - ) - issue_assignments = issue_assignments_json.get("total_count", 0) - metrics = get_user_contribution_metrics(username, token) - pr_data = metrics.get("data", {}).get("user", {}).get("pullRequests", {}).get("nodes", []) - for pr in pr_data: - lines_added += pr.get("additions", 0) - lines_deleted += pr.get("deletions", 0) - - # Update user_data with additional metrics - user_data.update( - { - "reactions_received": user_data.get("reactions_received", 0), - "mentorship_score": user_data.get("mentorship_score", 0), - "collaboration_score": user_data.get("collaboration_score", 0), - "issue_assignments": issue_assignments, - } - ) - - # Prepare context for the template - context = { - "user": user_data, - "prs_created": prs_created, - "prs_merged": prs_merged, - "pr_reviews": pr_reviews, - "issues_created": issues_created, - "issue_comments": issue_comments, - "pr_comments": pr_comments, - "lines_added": lines_added, - "lines_deleted": lines_deleted, - "first_contribution_date": first_contribution_date, - "chart_data": { - "prs_created": prs_created, - "prs_merged": prs_merged, - "pr_reviews": pr_reviews, - "issues_created": issues_created, - "issue_assignments": issue_assignments, - "pr_comments": pr_comments, - "issue_comments": issue_comments, - "lines_added": lines_added, - "lines_deleted": lines_deleted, - "first_contribution_date": (first_contribution_date if first_contribution_date != "N/A" else "N/A"), - }, - } - - # Cache for 1 hour - cache.set(cache_key, context, 3600) - return render(request, "web/contributor_detail.html", context) - - -@login_required -def all_study_groups(request): - """Display all study groups across courses.""" - # Get all study groups - groups = StudyGroup.objects.all().order_by("-created_at") - - # Group study groups by course - courses_with_groups = {} - for group in groups: - if group.course not in courses_with_groups: - courses_with_groups[group.course] = [] - courses_with_groups[group.course].append(group) - - # Handle creating a new study group - if request.method == "POST": - course_id = request.POST.get("course") - name = request.POST.get("name") - description = request.POST.get("description") - max_members = request.POST.get("max_members", 10) - is_private = request.POST.get("is_private", False) == "on" # Convert checkbox to boolean - - try: - # Validate the input - if not course_id or not name or not description: - raise ValueError("All fields are required") - - # Get the course - course = Course.objects.get(id=course_id) - - # Create the group - group = StudyGroup.objects.create( - course=course, - creator=request.user, - name=name, - description=description, - max_members=int(max_members), - is_private=is_private, - ) - - # Add the creator as a member - group.members.add(request.user) - - messages.success(request, "Study group created successfully!") - return redirect("study_group_detail", group_id=group.id) - except Course.DoesNotExist: - messages.error(request, "Course not found.") - except ValueError as e: - messages.error(request, str(e)) - except Exception as e: - messages.error(request, f"Error creating study group: {str(e)}") - - # Get user's enrollments for the create group form - enrollments = request.user.enrollments.filter(status="approved").select_related("course") - enrolled_courses = [enrollment.course for enrollment in enrollments] - - return render( - request, - "web/study/all_groups.html", - { - "courses_with_groups": courses_with_groups, - "enrolled_courses": enrolled_courses, - }, - ) - - -@login_required -def membership_checkout(request, plan_id: int) -> HttpResponse: - """Display the membership checkout page.""" - plan = get_object_or_404(MembershipPlan, id=plan_id) - - # Default to monthly billing - billing_period = request.GET.get("billing_period", "monthly") - - context = { - "plan": plan, - "billing_period": billing_period, - "stripe_public_key": settings.STRIPE_PUBLISHABLE_KEY, - } - - return render(request, "checkout.html", context) - - -@login_required -def create_membership_subscription(request) -> JsonResponse: - """Create a new membership subscription.""" - if request.method != "POST": - return JsonResponse({"error": "Invalid request method"}, status=400) - - try: - data = json.loads(request.body) - plan_id = data.get("plan_id") - payment_method_id = data.get("payment_method_id") - billing_period = data.get("billing_period", "monthly") - - if not all([plan_id, payment_method_id, billing_period]): - return JsonResponse({"error": "Missing required fields"}, status=400) - - # Create subscription using helper function - result = create_subscription( - user=request.user, - plan_id=plan_id, - payment_method_id=payment_method_id, - billing_period=billing_period, - ) - - if not result["success"]: - return JsonResponse({"error": result["error"]}, status=400) - - # Helper function to extract client_secret - def get_client_secret(subscription): - """Extract client_secret safely from a subscription object.""" - if ( - hasattr(subscription, "latest_invoice") - and subscription.latest_invoice - and hasattr(subscription.latest_invoice, "payment_intent") - ): - return subscription.latest_invoice.payment_intent.client_secret - return None - - return JsonResponse( - { - "subscription": result["subscription"], - "client_secret": get_client_secret(result["subscription"]), - }, - ) - - except json.JSONDecodeError as e: - logger.warning("Invalid JSON in create_membership_subscription: %s", str(e)) - return JsonResponse({"error": "Invalid JSON format"}, status=400) - except stripe.error.CardError as e: - logger.warning("Card error in create_membership_subscription: %s", str(e)) - return JsonResponse({"error": "Card payment failed"}, status=400) - except stripe.error.StripeError as e: - logger.error("Stripe error in create_membership_subscription: %s", str(e)) - return JsonResponse({"error": "Payment processing error"}, status=500) - except KeyError as e: - logger.warning("Missing key in create_membership_subscription: %s", str(e)) - return JsonResponse({"error": "Invalid request data"}, status=400) - except Exception as e: - logger.error("Unexpected error in create_membership_subscription: %s", str(e)) - return JsonResponse({"error": "An internal error occurred"}, status=500) - - -@login_required -def membership_success(request) -> HttpResponse: - """Display the membership success page.""" - try: - membership = request.user.membership - context = { - "membership": membership, - } - return render(request, "membership_success.html", context) - except (AttributeError, ObjectDoesNotExist): - messages.info(request, "You don't have an active membership subscription.") - return redirect("index") - - -@login_required -def membership_settings(request) -> HttpResponse: - """Display the membership settings page.""" - try: - membership = request.user.membership - - # Get Stripe invoices - if membership.stripe_customer_id: - setup_stripe() - invoices = stripe.Invoice.list(customer=membership.stripe_customer_id, limit=12) - else: - invoices = [] - - # Get subscription events - events = MembershipSubscriptionEvent.objects.filter(user=request.user).order_by("-created_at")[:10] - - context = { - "membership": membership, - "invoices": invoices.data if hasattr(invoices, "data") and invoices.data else [], - "events": events, - } - return render(request, "membership_settings.html", context) - except (AttributeError, ObjectDoesNotExist): - return redirect("index") - - -@login_required -def cancel_membership(request) -> HttpResponse: - """Cancel the user's membership subscription.""" - if request.method != "POST": - return redirect("membership_settings") - - try: - result = cancel_subscription(request.user) - - if result["success"]: - messages.success( - request, - "Your subscription has been cancelled and will end at the current billing period.", - ) - else: - messages.error(request, result["error"]) - - except stripe.error.StripeError: - logger.exception("Stripe error in cancel_membership") - messages.error(request, "An internal error occurred") - except ObjectDoesNotExist: - messages.error(request, "No membership found for your account.") - except Exception: - logger.exception("Unexpected error in cancel_membership") - messages.error(request, "An internal error occurred") - - return redirect("membership_settings") - - -@login_required -def reactivate_membership(request) -> HttpResponse: - """Reactivate a cancelled membership subscription.""" - if request.method != "POST": - return redirect("membership_settings") - - try: - result = reactivate_subscription(request.user) - - if result["success"]: - messages.success(request, "Your subscription has been reactivated.") - else: - messages.error(request, result["error"]) - - except stripe.error.StripeError as e: - logger.error("Stripe error in reactivate_membership: %s", str(e)) - messages.error(request, "An internal error occurred") - except ObjectDoesNotExist: - messages.error(request, "No membership found for your account.") - except Exception as e: - logger.error("Unexpected error in reactivate_membership: %s", str(e)) - messages.error(request, "An internal error occurred") - - return redirect("membership_settings") - - -@login_required -def update_payment_method(request) -> HttpResponse: - """Display the update payment method page.""" - try: - membership = request.user.membership - - # Get current payment method - if membership.stripe_customer_id: - setup_stripe() - payment_methods = stripe.PaymentMethod.list(customer=membership.stripe_customer_id, type="card") - current_payment_method = payment_methods.data[0] if payment_methods.data else None - else: - current_payment_method = None - - context = { - "membership": membership, - "current_payment_method": current_payment_method, - "stripe_public_key": settings.STRIPE_PUBLISHABLE_KEY, - } - return render(request, "update_payment_method.html", context) - except (AttributeError, ObjectDoesNotExist): - return redirect("membership_settings") - - -@login_required -def update_payment_method_api(request) -> JsonResponse: - """Update the payment method for a subscription.""" - if request.method != "POST": - return JsonResponse({"error": "Invalid request method"}, status=400) - - try: - data = json.loads(request.body) - payment_method_id = data.get("payment_method_id") - - if not payment_method_id: - return JsonResponse({"error": "Missing payment method ID"}, status=400) - - membership = request.user.membership - - if not membership.stripe_customer_id: - return JsonResponse({"error": "No active subscription found"}, status=400) - - setup_stripe() - - # Attach payment method to customer - stripe.PaymentMethod.attach(payment_method_id, customer=membership.stripe_customer_id) - - # Set as default payment method - stripe.Customer.modify( - membership.stripe_customer_id, - invoice_settings={"default_payment_method": payment_method_id}, - ) - - return JsonResponse({"success": True}) - - except stripe.error.CardError as e: - logger.warning("Card error in update_payment_method_api: %s", str(e)) - return JsonResponse({"error": "Card payment failed"}, status=400) - except stripe.error.InvalidRequestError as e: - logger.warning("Invalid request in update_payment_method_api: %s", str(e)) - return JsonResponse({"error": "Invalid payment method"}, status=400) - except stripe.error.StripeError as e: - logger.error("Stripe error in update_payment_method_api: %s", str(e)) - return JsonResponse({"error": "Payment processing error"}, status=500) - except Exception as e: - logger.error("Unexpected error in update_payment_method_api: %s", str(e)) - return JsonResponse({"error": "An internal error occurred"}, status=500) - - -def social_media_manager_required(user): - """Check if user has social media manager permissions.""" - return user.is_authenticated and (user.is_staff or getattr(user.profile, "is_social_media_manager", False)) - - -@user_passes_test(social_media_manager_required) -def get_twitter_client(): - """Initialize the Tweepy client.""" - auth = tweepy.OAuthHandler(settings.TWITTER_API_KEY, settings.TWITTER_API_SECRET_KEY) - auth.set_access_token(settings.TWITTER_ACCESS_TOKEN, settings.TWITTER_ACCESS_TOKEN_SECRET) - return tweepy.API(auth) - - -@user_passes_test(social_media_manager_required, login_url="/accounts/login/") -def social_media_dashboard(request): - # Fetch all posts that haven't been posted yet - posts = ScheduledPost.objects.filter(posted=False).order_by("-id") - return render(request, "social_media_dashboard.html", {"posts": posts}) - - -@user_passes_test(social_media_manager_required) -def post_to_twitter(request, post_id): - post = get_object_or_404(ScheduledPost, id=post_id) - if request.method == "POST": - client = get_twitter_client() - try: - if post.image: - # Upload the image file from disk - media = client.media_upload(post.image.path) - client.update_status(post.content, media_ids=[media.media_id]) - else: - client.update_status(post.content) - post.posted = True - post.posted_at = timezone.now() - post.save() - except Exception as e: - print(f"Error posting tweet: {e}") - return redirect("social_media_dashboard") - return redirect("social_media_dashboard") - - -@user_passes_test(social_media_manager_required) -def create_scheduled_post(request): - if request.method == "POST": - content = request.POST.get("content") - image = request.FILES.get("image") # Get the uploaded image, if provided. - if not content: - messages.error(request, "Post content cannot be empty.") - return redirect("social_media_dashboard") - ScheduledPost.objects.create( - content=content, image=image, scheduled_time=timezone.now() # This saves the image file. - ) - messages.success(request, "Post created successfully!") - return redirect("social_media_dashboard") - - -@user_passes_test(social_media_manager_required) -def delete_post(request, post_id): - """Delete a scheduled post.""" - post = get_object_or_404(ScheduledPost, id=post_id) - if request.method == "POST": - post.delete() - return redirect("social_media_dashboard") - - -def generate_discount_code(length=8): - return "".join(random.choices(string.ascii_uppercase + string.digits, k=length)) - - -@login_required -def apply_discount_via_referrer(request) -> HttpResponse: - """Apply a discount code when a user shares a course on Twitter. - - Args: - request: The HTTP request object - - Returns: - HttpResponse: A redirect to the profile page or an error response - """ - if request.method == "GET": - course_id = request.GET.get("course_id") - if not course_id: - return HttpResponseBadRequest("Course ID not provided.") - - course = get_object_or_404(Course, id=course_id) - - # Validate that the referrer is from Twitter - referrer = request.META.get("HTTP_REFERER", "").lower() - valid_twitter_domains = ["twitter.com", "t.co", "x.com"] - is_valid_referrer = any(domain in referrer for domain in valid_twitter_domains) - - # Skip the referrer check in development environment for testing - if settings.DEBUG: - logger.warning("Bypassing Twitter referrer check in DEBUG mode") - elif not is_valid_referrer: - messages.error(request, "You must click the link from Twitter to claim your discount.") - return redirect("profile") - # Create or retrieve an existing discount record. - discount = Discount.objects.filter(user=request.user, course=course, used=False).first() - if discount is None: - discount = Discount.objects.create( - user=request.user, - course=course, - code=generate_discount_code(), - discount_percentage=5.00, - valid_from=timezone.now(), - valid_until=default_valid_until(), - ) - - messages.success(request, "Thank you for sharing! Your discount code is now available in your profile.") - # Redirect user to their profile where discount codes are rendered. - return redirect("profile") - else: - return HttpResponseBadRequest("Invalid request method.") - - -def users_list(request: HttpRequest) -> HttpResponse: - """ - Display a list of users who have their profile set to public, - ordered by most recent updates. - """ - profiles = Profile.objects.filter(is_profile_public=True).select_related("user").order_by("-updated_at") - - # Add statistics for each user to create fun scorecards - for profile in profiles: - if profile.is_teacher: - # Teacher stats - courses = Course.objects.filter(teacher=profile.user).prefetch_related("enrollments", "reviews") - profile.total_courses = courses.count() - profile.total_students = sum(course.enrollments.filter(status="approved").count() for course in courses) - # Get average rating across all courses - course_ratings = [course.average_rating for course in courses if course.average_rating > 0] - profile.avg_rating = round(sum(course_ratings) / len(course_ratings), 1) if course_ratings else 0 - else: - # Student stats - enrollments = Enrollment.objects.filter(student=profile.user).select_related("course") - profile.total_courses = enrollments.count() - completed_enrollments = enrollments.filter(status="completed") - profile.total_completed = completed_enrollments.count() - - # Calculate average progress across all courses - total_progress = 0 - progress_count = 0 - enrollment_ids = [e.id for e in enrollments] - existing_progresses = { - p.enrollment_id: p for p in CourseProgress.objects.filter(enrollment_id__in=enrollment_ids) - } - - for enrollment in enrollments: - progress = existing_progresses.get(enrollment.id) - if not progress: - progress = CourseProgress.objects.create(enrollment=enrollment) - total_progress += progress.completion_percentage - progress_count += 1 - profile.avg_progress = round(total_progress / progress_count) if progress_count > 0 else 0 - - # Add achievements count - profile.achievements_count = Achievement.objects.filter(student=profile.user).count() - - # Pagination: 12 profiles per page - paginator = Paginator(profiles, 12) - page_number = request.GET.get("page") - page_obj = paginator.get_page(page_number) - - context = { - "page_obj": page_obj, - } - - return render(request, "users_list.html", context) - - -@login_required -def topic_vote(request, pk): - """Handle voting on a topic.""" - if request.method != "POST": - return JsonResponse({"error": "Only POST method allowed"}, status=405) - - try: - topic = ForumTopic.objects.get(pk=pk) - vote_type = request.POST.get("vote_type") - - if vote_type not in ["up", "down"]: - # For form submissions, redirect back with an error message if needed - messages.error(request, "Invalid vote type") - return redirect("topic_vote", pk=topic.id) - - # Check if user already voted on this topic - vote, created = ForumVote.objects.get_or_create( - user=request.user, topic=topic, defaults={"vote_type": vote_type} - ) - - if not created: - # User already voted, check if they're changing their vote - if vote.vote_type == vote_type: - # Same vote type, so remove the vote - vote.delete() - else: - # Different vote type, so update the vote - vote.vote_type = vote_type - vote.save() - - # After processing the vote, redirect back to the topic page - return redirect("forum_topic", category_slug=topic.category.slug, topic_id=topic.id) - - except ForumTopic.DoesNotExist: - # Handle case when topic doesn't exist - messages.error(request, "Topic not found") - return redirect("forum_categories") - - -@login_required -def reply_vote(request, pk): - """Handle voting on a reply.""" - if request.method != "POST": - return JsonResponse({"error": "Only POST method allowed"}, status=405) - - try: - reply = ForumReply.objects.get(pk=pk) - vote_type = request.POST.get("vote_type") - - if vote_type not in ["up", "down"]: - messages.error(request, "Invalid vote type") - return redirect("forum_topic", category_slug=reply.topic.category.slug, topic_id=reply.topic.id) - - # Check if user already voted on this reply - vote, created = ForumVote.objects.get_or_create( - user=request.user, reply=reply, defaults={"vote_type": vote_type} - ) - - if not created: - # User already voted, check if they're changing their vote - if vote.vote_type == vote_type: - # Same vote type, so remove the vote - vote.delete() - else: - # Different vote type, so update the vote - vote.vote_type = vote_type - vote.save() - - # After processing the vote, redirect back to the topic page - return redirect("forum_topic", category_slug=reply.topic.category.slug, topic_id=reply.topic.id) - - except ForumReply.DoesNotExist: - messages.error(request, "Reply not found") - return redirect("forum_categories") - - -def topic_detail(request, pk): - topic = get_object_or_404(ForumTopic, pk=pk) - - # Get the user's vote on this topic if any - user_topic_vote = None - if request.user.is_authenticated: - try: - vote = ForumVote.objects.get(topic=topic, user=request.user) - user_topic_vote = vote.vote_type - except ForumVote.DoesNotExist: - pass - - # Get user votes on replies - user_reply_votes = {} - if request.user.is_authenticated: - reply_votes = ForumVote.objects.filter(reply__topic=topic, user=request.user).values_list( - "reply_id", "vote_type" - ) - user_reply_votes = dict(reply_votes) - - context = { - "topic": topic, - "user_topic_vote": user_topic_vote, - "user_reply_votes": user_reply_votes, - # other context variables - } - - return render(request, "web/forum/topic.html", context) - - -def contributors_list_view(request): - # Check if cached data is available - cached_context = cache.get("contributors_context") - if cached_context: - return render(request, "web/contributors_list.html", cached_context) - - # Initialize a dictionary to track contributor stats - contributor_stats = {} - - # Function to add a contributor to our stats dictionary - def add_contributor(username, avatar_url, profile_url): - if username not in contributor_stats: - contributor_stats[username] = { - "username": username, - "avatar_url": avatar_url, - "profile_url": profile_url, - "merged_pr_count": 0, - "closed_pr_count": 0, - "open_pr_count": 0, - "total_pr_count": 0, - "prs_url": f"https://github.com/AlphaOneLabs/education-website/pulls?q=is:pr+author:{username}", - } - - try: - # Fetch closed PRs first (includes both merged and non-merged closed PRs) - closed_prs = [] - for page in range(1, 11): # Limit to 10 pages to prevent hitting API rate limits - response = github_api_request( - f"{GITHUB_API_BASE}/repos/AlphaOneLabs/education-website/pulls", - params={"state": "closed", "per_page": 100, "page": page}, - ) - if not response or len(response) == 0: - break - - closed_prs.extend(response) - time.sleep(0.5) # Add delay to avoid hitting rate limits - - # Process closed PRs - for pr in closed_prs: - username = pr["user"]["login"] - - # Skip bots and specific users - if "[bot]" in username or "dependabot" in username or username == "A1L13N": - continue - - avatar_url = pr["user"]["avatar_url"] - profile_url = pr["user"]["html_url"] - - # Add to our tracking - add_contributor(username, avatar_url, profile_url) - - # Update the appropriate count based on whether it was merged - if pr["merged_at"]: - contributor_stats[username]["merged_pr_count"] += 1 - else: - contributor_stats[username]["closed_pr_count"] += 1 - - # Now fetch open PRs - open_prs = [] - for page in range(1, 6): # Limit to 5 pages for open PRs - response = github_api_request( - f"{GITHUB_API_BASE}/repos/AlphaOneLabs/education-website/pulls", - params={"state": "open", "per_page": 100, "page": page}, - ) - if not response or len(response) == 0: - break - - open_prs.extend(response) - time.sleep(0.5) # Add delay to avoid hitting rate limits - - # Process open PRs - for pr in open_prs: - username = pr["user"]["login"] - - # Skip bots and specific users - if "[bot]" in username or "dependabot" in username or username == "A1L13N": - continue - - avatar_url = pr["user"]["avatar_url"] - profile_url = pr["user"]["html_url"] - - # Add to our tracking - add_contributor(username, avatar_url, profile_url) - - # Update open PR count - contributor_stats[username]["open_pr_count"] += 1 - - # Calculate total PR count and filter out users with no merged PRs - contributors = [] - for username, stats in contributor_stats.items(): - # Skip contributors with no merged PRs - if stats["merged_pr_count"] == 0: - continue - - # Calculate total PR count - stats["total_pr_count"] = stats["merged_pr_count"] + stats["closed_pr_count"] + stats["open_pr_count"] - - # Calculate a smart score that prioritizes merged PRs but penalizes imbalances - # Formula: (merged_pr_count * 10) - penalties for imbalanced contributions - smart_score = stats["merged_pr_count"] * 10 - - # Penalize if closed PRs are more than half of merged PRs (could indicate issues with code quality) - if stats["closed_pr_count"] > (stats["merged_pr_count"] / 2): - smart_score -= (stats["closed_pr_count"] - (stats["merged_pr_count"] / 2)) * 2 - - # Penalize if open PRs are more than merged PRs (could indicate abandonment issues) - if stats["open_pr_count"] > stats["merged_pr_count"]: - smart_score -= stats["open_pr_count"] - stats["merged_pr_count"] - - # Calculate a contribution ratio: merged/(total) - higher is better - if stats["total_pr_count"] > 0: - stats["contribution_ratio"] = stats["merged_pr_count"] / stats["total_pr_count"] - else: - stats["contribution_ratio"] = 0 - - # Store the smart score - stats["smart_score"] = smart_score - - contributors.append(stats) - - # Sort by smart score (primary) and then by merged PR count (secondary) - contributors.sort(key=lambda x: (x["smart_score"], x["merged_pr_count"]), reverse=True) - - # Store the context in cache for 12 hours - context = {"contributors": contributors} - cache.set("contributors_context", context, 12 * 60 * 60) - - return render(request, "web/contributors_list.html", context) - - except Exception as e: - # Log the error - print(f"Error fetching contributors: {e}") - # Return an empty list in case of error - return render(request, "web/contributors_list.html", {"contributors": []}) - - -@login_required -def video_request_list(request): - """View for listing video requests with optional category filtering.""" - # Get category filter from query params - selected_category = request.GET.get("category") - - # Base queryset - requests = VideoRequest.objects.select_related("requester", "category").order_by("-created_at") - - # Apply category filter if provided - if selected_category: - requests = requests.filter(category__slug=selected_category) - selected_category_obj = get_object_or_404(Subject, slug=selected_category) - selected_category_display = selected_category_obj.name - else: - selected_category_display = None - - # Get category counts for sidebar - category_counts = { - category.slug: VideoRequest.objects.filter(category=category).count() for category in Subject.objects.all() - } - - # Context - context = { - "requests": requests, - "categories": Subject.objects.all(), - "category_counts": category_counts, - "selected_category": selected_category, - "selected_category_display": selected_category_display, - } - - return render(request, "videos/request_list.html", context) - - -@login_required -def submit_video_request(request: HttpRequest) -> HttpResponse: - """View for submitting a new video request.""" - if request.method == "POST": - form = VideoRequestForm(request.POST) - if form.is_valid(): - video_request = form.save(commit=False) - video_request.requester = request.user - video_request.save() - - messages.success(request, "Your video request has been submitted successfully!") - return redirect("video_request_list") - else: - form = VideoRequestForm() - - return render(request, "videos/submit_request.html", {"form": form}) - - -class SurveyListView(LoginRequiredMixin, ListView): - model = Survey - template_name = "surveys/list.html" - login_url = "/accounts/login/" - - -class SurveyCreateView(LoginRequiredMixin, CreateView): - model = Survey - form_class = SurveyForm - template_name = "surveys/create.html" - login_url = "/accounts/login/" - - def form_valid(self, form): - form.instance.author = self.request.user - survey = form.save() - - # Process questions - question_texts = self.request.POST.getlist("question_text[]") - question_types = self.request.POST.getlist("question_type[]") - question_choices = self.request.POST.getlist("question_choices[]") - scale_mins = self.request.POST.getlist("scale_min[]") - scale_maxs = self.request.POST.getlist("scale_max[]") - - for i, (q_text, q_type) in enumerate(zip(question_texts, question_types)): - if q_text.strip(): - # Convert scale values to integers with proper error handling - scale_min = 1 - scale_max = 5 - - try: - if q_type == "scale" and i < len(scale_mins) and scale_mins[i]: - scale_min = int(scale_mins[i]) - if q_type == "scale" and i < len(scale_maxs) and scale_maxs[i]: - scale_max = int(scale_maxs[i]) - except (ValueError, IndexError): - # Use defaults if there's an error - scale_min = 1 - scale_max = 5 - - question = Question.objects.create( - survey=survey, - text=q_text.strip(), - type=q_type, # This should match your model field name - scale_min=scale_min, - scale_max=scale_max, - ) - - # Handle choices based on question type - if q_type == "true_false": - Choice.objects.create(question=question, text="True") - Choice.objects.create(question=question, text="False") - elif q_type == "scale": - for num in range(question.scale_min, question.scale_max + 1): - Choice.objects.create(question=question, text=str(num)) - elif q_type in ["mcq", "checkbox"]: - # Make sure we have choices for this question - if i < len(question_choices): - for choice_text in question_choices[i].split("\n"): - if choice_text.strip(): - Choice.objects.create(question=question, text=choice_text.strip()) - - return redirect("surveys") - - -class SurveyDetailView(LoginRequiredMixin, DetailView): - model = Survey - template_name = "surveys/detail.html" - login_url = "/accounts/login/" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - # Check if user has already submitted this survey - context["already_submitted"] = Response.objects.filter( - user=self.request.user, question__survey=self.object - ).exists() - # Check if user is the creator of this survey - context["is_creator"] = self.object.author == self.request.user - return context - - -@login_required -def submit_survey(request, pk): - survey = get_object_or_404(Survey, pk=pk) - - # Check if user already submitted - if Response.objects.filter(user=request.user, question__survey=survey).exists(): - messages.error(request, "You've already submitted this survey!") - return redirect("survey-detail", pk=survey.id) - - if request.method == "POST": - for question in survey.question_set.all(): - if question.required and not request.POST.get(f"question_{question.id}"): - messages.error(request, f"Please answer required question: {question.text}") - return redirect("survey-detail", pk=survey.id) - - if question.type == "checkbox": - choices = request.POST.getlist(f"question_{question.id}") - for choice_id in choices: - try: - choice = Choice.objects.get(id=choice_id) - if choice.question_id == question.id: - Response.objects.create(user=request.user, question=question, choice=choice) - else: - messages.error(request, "Invalid choice selected") - return redirect("survey-detail", pk=survey.id) - except Choice.DoesNotExist: - messages.error(request, "Invalid choice selected") - return redirect("survey-detail", pk=survey.id) - elif question.type == "text": - Response.objects.create( - user=request.user, question=question, text_answer=request.POST.get(f"question_{question.id}") - ) - else: - choice_id = request.POST.get(f"question_{question.id}") - if choice_id: - try: - choice = Choice.objects.get(id=choice_id) - if choice.question_id == question.id: - Response.objects.create(user=request.user, question=question, choice=choice) - else: - messages.error(request, "Invalid choice selected") - return redirect("survey-detail", pk=survey.id) - except Choice.DoesNotExist: - messages.error(request, "Invalid choice selected") - return redirect("survey-detail", pk=survey.id) - - messages.success(request, "Survey submitted successfully!") - return redirect("survey-results", pk=survey.id) - - return redirect("survey-detail", pk=survey.id) - - -class SurveyResultsView(LoginRequiredMixin, DetailView): - model = Survey - template_name = "surveys/results.html" - login_url = "/accounts/login/" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # Process survey results - results = [] - total_participants = 0 - most_answered_question = None - max_responses = 0 - top_choice = None - bottom_choice = None - overall_top_choice_count = 0 - overall_bottom_choice_count = float("inf") - total_possible_responses = 0 - total_actual_responses = 0 - avg_completion_time = None - context["avg_completion_time"] = avg_completion_time - - for question in self.object.question_set.all(): - choices_data = [] - question_total = 0 - - # Process each choice for this question - for choice in question.choice_set.all(): - count = choice.response_set.count() if hasattr(choice, "response_set") else 0 - - question_total += count - choices_data.append({"text": choice.text, "count": count}) - - if question_total == 0: - continue - if question_total > 0: - total_possible_responses += total_participants * 1 # Each participant could answer this question - total_actual_responses += question_total - - if question_total > max_responses: - max_responses = question_total - most_answered_question = question - - for choice in choices_data: - choice["percentage"] = round((choice["count"] / question_total * 100), 1) if question_total > 0 else 0 - - if choice["count"] > overall_top_choice_count: - overall_top_choice_count = choice["count"] - top_choice = choice - - if choice["count"] > 0 and choice["count"] < overall_bottom_choice_count: - overall_bottom_choice_count = choice["count"] - bottom_choice = choice - - results.append({"question": question, "choices": choices_data, "total": question_total}) - - total_participants = max(total_participants, question_total) - engagement_score = 0 - if total_possible_responses > 0: - engagement_score = (total_actual_responses / total_possible_responses) * 100 - - context["engagement_score"] = round(engagement_score, 1) - target_participants = getattr(self.object, "target_participants", 100) # default to 100 if not set - response_rate = (total_participants / target_participants * 100) if target_participants > 0 else 0 - - context.update( - { - "results": results, - "total_participants": total_participants, - "response_rate": min(response_rate, 100), # Cap at 100% - "most_answered_question": most_answered_question, - "top_choice": top_choice, - "bottom_choice": bottom_choice, - "is_creator": self.object.author == self.request.user if hasattr(self.object, "author") else False, - } - ) - - chart_data = [] - for result in results: - chart_data.append( - { - "question_id": result["question"].id, - "labels": [choice["text"] for choice in result["choices"]], - "data": [choice["count"] for choice in result["choices"]], - } - ) - context["chart_data_json"] = json.dumps(chart_data) - - return context - - -class SurveyDeleteView(LoginRequiredMixin, DeleteView): - model = Survey - success_url = reverse_lazy("surveys") # Use reverse_lazy - template_name = "surveys/delete.html" - login_url = "/accounts/login/" - - def get_queryset(self): - # Override queryset to only allow creator to access the survey for deletion - base_qs = super().get_queryset() - return base_qs.filter(author=self.request.user) - - def handle_no_permission(self): - messages.error(self.request, "You can only delete surveys that you created.") - return redirect("surveys") - - -@login_required -def join_session_waiting_room(request, course_slug): - """View for joining a session waiting room for the next session of a course.""" - course = get_object_or_404(Course, slug=course_slug) - - # Get or create the session waiting room for this course - session_waiting_room, created = WaitingRoom.objects.get_or_create( - course=course, status="open", defaults={"status": "open"} - ) - - # Check if the waiting room is open - if session_waiting_room.status != "open": - messages.error(request, "This session waiting room is no longer open for joining.") - return redirect("course_detail", slug=course_slug) - - # Add the user to participants if not already in - if request.user not in session_waiting_room.participants.all(): - session_waiting_room.participants.add(request.user) - next_session = session_waiting_room.get_next_session() - if next_session: - next_session_date = next_session.start_time.strftime("%B %d, %Y at %I:%M %p") - messages.success( - request, - f"You have joined the waiting room for the next session of {course.title}. " - f"Next session is on {next_session_date}.", - ) - else: - messages.success( - request, - f"You have joined the waiting room for the next session of {course.title}. " - f"You'll be notified when a new session is scheduled.", - ) - notify_teacher_waiting_room_join(session_waiting_room, request.user) - else: - messages.info(request, "You are already in the waiting room for the next session of this course.") - - return redirect("course_detail", slug=course_slug) - - -@login_required -def leave_session_waiting_room(request, course_slug): - """View for leaving a session waiting room.""" - course = get_object_or_404(Course, slug=course_slug) - - try: - session_waiting_room = WaitingRoom.objects.get(course=course, status="open") - except WaitingRoom.DoesNotExist: - messages.info(request, "No session waiting room found for this course.") - return redirect("course_detail", slug=course_slug) - - # Remove the user from participants - if request.user in session_waiting_room.participants.all(): - session_waiting_room.participants.remove(request.user) - messages.success(request, f"You have left the session waiting room for {course.title}") - else: - messages.info(request, "You are not in the session waiting room for this course.") - - return redirect("course_detail", slug=course_slug) diff --git a/web/virtual_lab/__init__.py b/web/virtual_lab/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/web/virtual_lab/apps.py b/web/virtual_lab/apps.py deleted file mode 100644 index fc003d2..0000000 --- a/web/virtual_lab/apps.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Django AppConfig for the Virtual Lab application.""" - -from django.apps import AppConfig - - -class VirtualLabConfig(AppConfig): - """Configuration for the Virtual Lab Django application.""" - - default_auto_field = "django.db.models.BigAutoField" - name = "web.virtual_lab" diff --git a/web/virtual_lab/css/virtual_lab.css b/web/virtual_lab/css/virtual_lab.css deleted file mode 100644 index e69de29..0000000 diff --git a/web/virtual_lab/models.py b/web/virtual_lab/models.py deleted file mode 100644 index e69de29..0000000 diff --git a/web/virtual_lab/static/virtual_lab/js/code_editor.js b/web/virtual_lab/static/virtual_lab/js/code_editor.js deleted file mode 100644 index f531817..0000000 --- a/web/virtual_lab/static/virtual_lab/js/code_editor.js +++ /dev/null @@ -1,53 +0,0 @@ -// static/virtual_lab/js/code_editor.js - -// Simple CSRF helper -function getCookie(name) { - const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`)); - return match ? decodeURIComponent(match[2]) : null; -} - -// Bootstrap Ace -const editor = ace.edit("editor"); -editor.setTheme("ace/theme/github"); -editor.session.setMode("ace/mode/python"); -editor.setOptions({ fontSize: "14px", showPrintMargin: false }); - -const runBtn = document.getElementById("run-btn"); -const outputEl = document.getElementById("output"); -const stdinEl = document.getElementById("stdin-input"); -const langSel = document.getElementById("language-select"); - -runBtn.addEventListener("click", () => { - const code = editor.getValue(); - const stdin = stdinEl.value; - const language = langSel.value; - - if (!code.trim()) { - outputEl.textContent = "🛑 Please type some code first."; - return; - } - outputEl.textContent = "Running…"; - runBtn.disabled = true; - - fetch(window.EVALUATE_CODE_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": getCookie("csrftoken") - }, - body: JSON.stringify({ code, language, stdin }) - }) - .then(res => res.json()) - .then(data => { - let out = ""; - if (data.stderr) out += `ERROR:\n${data.stderr}\n`; - if (data.stdout) out += data.stdout; - outputEl.textContent = out || "[no output]"; - }) - .catch(err => { - outputEl.textContent = `Request failed: ${err.message}`; - }) - .finally(() => { - runBtn.disabled = false; - }); -}); diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/index.html b/web/virtual_lab/templates/virtual_lab/chemistry/index.html deleted file mode 100644 index 8c09f72..0000000 --- a/web/virtual_lab/templates/virtual_lab/chemistry/index.html +++ /dev/null @@ -1,58 +0,0 @@ -{# templates/virtual_lab/chemistry/index.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} -{% load i18n %} - -{% block virtual_lab_content %} - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/ph_indicator.html b/web/virtual_lab/templates/virtual_lab/chemistry/ph_indicator.html deleted file mode 100644 index 458a40f..0000000 --- a/web/virtual_lab/templates/virtual_lab/chemistry/ph_indicator.html +++ /dev/null @@ -1,58 +0,0 @@ -{# templates/virtual_lab/chemistry/ph_indicator.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} -{% load i18n %} - -{% block virtual_lab_content %} -
-

{% trans "pH Indicator" %}

-
- -
- - - -
- {% trans "Enter a pH value (0–14) and click Update to see the color change." %} -
-
-
- -
- - - -
-
-
-{% endblock %} -{% block extra_scripts %} - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/precipitation.html b/web/virtual_lab/templates/virtual_lab/chemistry/precipitation.html deleted file mode 100644 index aca5027..0000000 --- a/web/virtual_lab/templates/virtual_lab/chemistry/precipitation.html +++ /dev/null @@ -1,56 +0,0 @@ -{# templates/virtual_lab/chemistry/precipitation.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} -{% load i18n %} - -{% block virtual_lab_content %} -
-

{% trans "Precipitation Reaction" %}

-
- -
- - - -
- {% trans "Click Add Reagent to begin." %} -
-
-
- -
- - - - - - -
-
-
-{% endblock %} -{% block extra_scripts %} - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/reaction_rate.html b/web/virtual_lab/templates/virtual_lab/chemistry/reaction_rate.html deleted file mode 100644 index 5154fd6..0000000 --- a/web/virtual_lab/templates/virtual_lab/chemistry/reaction_rate.html +++ /dev/null @@ -1,54 +0,0 @@ -{# templates/virtual_lab/chemistry/reaction_rate.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} -{% load i18n %} - -{% block extra_head %}{# no extra CSS needed #}{% endblock %} -{% block virtual_lab_content %} -
-

{% trans "Reaction Rate" %}

-
- -
- - - -
- {% trans "Elapsed Time:" %} - 0 {% trans "s" %} -
-
- {% trans "Set the concentration and start to see the reaction proceed..." %} -
-
- {# will display "Reaction Complete" at the end #} -
-
- - -
-
-{% endblock %} -{% block extra_scripts %} - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/solubility.html b/web/virtual_lab/templates/virtual_lab/chemistry/solubility.html deleted file mode 100644 index b8fd25e..0000000 --- a/web/virtual_lab/templates/virtual_lab/chemistry/solubility.html +++ /dev/null @@ -1,53 +0,0 @@ -{# templates/virtual_lab/chemistry/solubility.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} -{% load i18n %} - -{% block virtual_lab_content %} -
-

{% trans "Solubility & Saturation" %}

-
- -
- - - -
- {% trans "Dissolved:" %} - 0 g -
-
- {% trans "Solution is unsaturated. Add more solute to test saturation." %} -
-
- {# final property will appear here #} -
-
- - -
-
-{% endblock %} -{% block extra_scripts %} - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/chemistry/titration.html b/web/virtual_lab/templates/virtual_lab/chemistry/titration.html deleted file mode 100644 index cda887c..0000000 --- a/web/virtual_lab/templates/virtual_lab/chemistry/titration.html +++ /dev/null @@ -1,78 +0,0 @@ -{# templates/virtual_lab/chemistry/titration.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} -{% load i18n %} - -{% block extra_head %}{# no extra CSS needed #}{% endblock %} -{% block virtual_lab_content %} -
-

{% trans "Acid-Base Titration" %}

-
- -
- - - - - -
- {% trans "Titrant Added:" %} - 0 mL -
-
- {% trans "Adjust the controls and start titration to see hints here..." %} -
-
- {# will display: “Solution is Acidic/Neutral/Basic” #} -
-
- -
- - -
-
-
-{% endblock %} -{% block extra_scripts %} - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/code_editor/code_editor.html b/web/virtual_lab/templates/virtual_lab/code_editor/code_editor.html deleted file mode 100644 index 882355c..0000000 --- a/web/virtual_lab/templates/virtual_lab/code_editor/code_editor.html +++ /dev/null @@ -1,82 +0,0 @@ -{# templates/virtual_lab/code_editor/code_editor.html #} -{% extends 'virtual_lab/layout.html' %} - -{% load static %} - -{% block title %} - Code Editor – Alpha Science Lab -{% endblock title %} -{% block extra_head %} - - - -{% endblock extra_head %} -{% block virtual_lab_content %} -
-

Interactive Code Editor

- -
print("Hello, world!")
- -
- - -
- -
- - -
- -
- -
- -
-

Output:

-

-    
-
-{% endblock virtual_lab_content %} -{% block extra_scripts %} - - -{% endblock extra_scripts %} diff --git a/web/virtual_lab/templates/virtual_lab/home.html b/web/virtual_lab/templates/virtual_lab/home.html deleted file mode 100644 index 159d14f..0000000 --- a/web/virtual_lab/templates/virtual_lab/home.html +++ /dev/null @@ -1,51 +0,0 @@ -{# web/virtual_lab/templates/virtual_lab/home.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} - -{% block virtual_lab_content %} - -{% endblock virtual_lab_content %} diff --git a/web/virtual_lab/templates/virtual_lab/layout.html b/web/virtual_lab/templates/virtual_lab/layout.html deleted file mode 100644 index efb38e5..0000000 --- a/web/virtual_lab/templates/virtual_lab/layout.html +++ /dev/null @@ -1,77 +0,0 @@ -{# web/virtual_lab/templates/virtual_lab/layout.html #} -{% extends "base.html" %} - -{% load static %} -{% load i18n %} - -{% block content %} - - - - {% block extra_scripts %} - {% endblock extra_scripts %} -{% endblock content %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/circuit.html b/web/virtual_lab/templates/virtual_lab/physics/circuit.html deleted file mode 100644 index 4bf278a..0000000 --- a/web/virtual_lab/templates/virtual_lab/physics/circuit.html +++ /dev/null @@ -1,157 +0,0 @@ -{# web/virtual_lab/templates/virtual_lab/physics/circuit.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} - -{% block virtual_lab_content %} -
- -
-
-

- Step 1 of 4 -

-
    - -
-
- - - -
-
-
- -
-
-
-

Basic Electrical Circuit

-

- Build a simple RC circuit: a battery \(V_0\), a resistor \(R\), and a capacitor \(C\). Adjust \(V_0\), \(R\), and \(C\), then click “Start” to watch the capacitor charge. Observe real‐time \(V_C(t)\), \(I(t)\), and a live graph of capacitor voltage over time. After \(5\tau\), a quiz appears. -

-
-
- -
- -
-
- - - 5.0 V -
-
- - - 100 Ω -
-
- - - 100 µF -
-
- - - -
-
- -
- - -
-

- Time: 0.00 s -

-

- Voltage \(V_C\): 0.00 V -

-

- Current \(I\): 0.00 A -

-

- Time Constant \(\tau\): 0.01 s -

-
-
- - -
- -
- -
-

Capacitor Voltage vs Time

- -
- -
-

Current vs Time

- -
-
-
-
-
-
- - - - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/inclined.html b/web/virtual_lab/templates/virtual_lab/physics/inclined.html deleted file mode 100644 index 719b840..0000000 --- a/web/virtual_lab/templates/virtual_lab/physics/inclined.html +++ /dev/null @@ -1,179 +0,0 @@ -{# web/virtual_lab/templates/virtual_lab/physics/inclined.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} - -{% block virtual_lab_content %} -
- -
-
-

- Step 1 of 4 -

-
    - -
-
- - - -
-
-
- -
-
-
-

Inclined Plane Dynamics

-

- Drag the block to any starting point, adjust angle, friction, and mass. - Click “Launch” to let it slide, watch live readouts, energy bars, force vectors, and a real-time graph. - Once it reaches the bottom, a short quiz will appear. -

-
-
-
-
-
- - - 30° -
-
- - - 0.00 -
-
- - - 1.0 kg -
-
- - - -
-
- -
- - -
-

- Distance ↓: 0.00 m -

-

- Speed: 0.00 m/s -

-

- Accel: 0.00 m/s² -

-

- PE: 0.00 J -

-

- KE: 0.00 J -

-
-
- -
-
-
- mg sin α -
-
-
- Normal (mg cos α) -
-
-
- Friction (μ mg cos α) -
-
-
- -
- -
-

Position vs Time

- -
- -
-
-

Potential Energy

-
- -
-
-
-

Kinetic Energy

-
- -
-
-
- - -
-
-
-
-
- - - - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/mass_spring.html b/web/virtual_lab/templates/virtual_lab/physics/mass_spring.html deleted file mode 100644 index b541b16..0000000 --- a/web/virtual_lab/templates/virtual_lab/physics/mass_spring.html +++ /dev/null @@ -1,178 +0,0 @@ -{# web/virtual_lab/templates/virtual_lab/physics/mass_spring.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} - -{% block virtual_lab_content %} -
- -
-
-

- Step 1 of 4 -

-
    - -
-
- - - -
-
-
- -
-
-
-

Mass–Spring Oscillation

-

- Drag the mass horizontally to set its initial displacement. Adjust the spring constant \(k\) and mass \(m\). - Click “Start” to see the mass oscillate. A live Position vs. Time graph and numeric readouts will update in real time. - After one full oscillation, a post-lab quiz will appear. -

-
-
- -
- -
-
- - - 25 -
-
- - - 1.0 kg -
-
- - - 0.20 m -
-
- - - -
-
- -
- - -
-

- Time: 0.00 s -

-

- Position: 0.00 m -

-

- Velocity: 0.00 m/s -

-

- Acceleration: 0.00 m/s² -

-

- Potential (½kx²): 0.00 J -

-

- Kinetic (½mv²): 0.00 J -

-
-
- - -
- -
- -
-

Position vs Time

- -
- -
-
-

Potential Energy

-
- -
-
-
-

Kinetic Energy

-
- -
-
-
-
-
-
-
-
- - - - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/pendulum.html b/web/virtual_lab/templates/virtual_lab/physics/pendulum.html deleted file mode 100644 index e9a4f6a..0000000 --- a/web/virtual_lab/templates/virtual_lab/physics/pendulum.html +++ /dev/null @@ -1,127 +0,0 @@ -{# web/virtual_lab/templates/virtual_lab/physics/pendulum.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} - -{% block virtual_lab_content %} -
- {# ---------------- Tutorial Overlay (unchanged) ---------------- #} -
-
-

- Step 1 of 5 -

-
    - {# Animated bullet points inserted by JS #} -
-
- - - -
-
-
- {# -------------- End Tutorial Overlay -------------- #} -
-
- -
-

Pendulum Motion

-

- Follow the animated tutorial above, then start the simulation. Watch the pendulum swing and see its trail. Numerical readouts appear at top‐left. -

-
- {# -------- Pendulum area with trail, mini-graph, and readouts -------- #} -
- - - {# Numerical Readouts in top-left corner #} -
-
- t = 0.00 s -
-
- θ = 0.0° -
-
- v = 0.0 m/s -
-
- {# Small graph overlaid in top-right #} -
-

θ vs t

- - -
-
- {# -------- End Pendulum area -------- #} - {# -------- Energy Bars -------- #} -
-
-
Potential Energy
-
-
-
-
-
-
Kinetic Energy
-
-
-
-
-
- {# -------- End Energy Bars -------- #} - -
-
- - - 1.0 m -
- - -
- - -
-
-
- {# Include the updated JS with audio references removed #} - -{% endblock %} diff --git a/web/virtual_lab/templates/virtual_lab/physics/projectile.html b/web/virtual_lab/templates/virtual_lab/physics/projectile.html deleted file mode 100644 index af9e51d..0000000 --- a/web/virtual_lab/templates/virtual_lab/physics/projectile.html +++ /dev/null @@ -1,158 +0,0 @@ -{# web/virtual_lab/templates/virtual_lab/physics/projectile.html #} -{% extends "virtual_lab/layout.html" %} - -{% load static %} - -{% block virtual_lab_content %} -
- {# ---------------- Pre-Lab Tutorial Overlay ---------------- #} -
-
-

- Step 1 of 5 -

-
    - {# JS will inject bullet points here with fade-in #} -
-
- - - -
-
-
- {# -------------- End Tutorial Overlay -------------- #} -
-
- -
-

Projectile Motion

-

- Click‐and‐drag from the launch pad (white circle) at left to set speed and angle. -
- Release to fire. Adjust gravity or wind on the fly. - Watch the path and vectors, and see “y vs x” plotted live. -

-
- {# -------- Main Simulation Area: Canvas + Plot -------- #} -
- {# ---------------- Projectile Canvas ---------------- #} -
- - - {# Launch Pad Icon (white circle)—drawn via JS, but reserve a tooltip #} -
- - Launch Pad -
- {# Numeric Readouts (top-left) #} -
-
- t = 0.00 s -
-
- x = 0.00 m -
-
- y = 0.00 m -
-
- vₓ = 0.00 m/s -
-
- v_y = 0.00 m/s -
-
- {# Target Indicator (blue vertical marker), drawn by JS #} -
- {# ---------------- Live Plot: y vs x ---------------- #} -
-

Trajectory: y vs x

- - -
-
- {# -------- End Simulation + Plot -------- #} - {# -------- Mid-Flight “What-If” Controls -------- #} -
-
Mid-Flight Controls:
-
-
- - -
-
- - -
-
- -
-
-
-
- {# -------- End Mid-Flight Controls -------- #} - {# -------- Post-Lab Quiz / Target Info (hidden initially) -------- #} - - {# -------- End Quiz -------- #} -
-
-
- {# Include the JavaScript for all new features #} - -{% endblock %} diff --git a/web/virtual_lab/tests.py b/web/virtual_lab/tests.py deleted file mode 100644 index e69de29..0000000 diff --git a/web/virtual_lab/urls.py b/web/virtual_lab/urls.py deleted file mode 100644 index bd8b671..0000000 --- a/web/virtual_lab/urls.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.urls import path - -from .views import ( - chemistry_home, - code_editor_view, - evaluate_code, - ph_indicator_view, - physics_electrical_circuit_view, - physics_inclined_view, - physics_mass_spring_view, - physics_pendulum_view, - physics_projectile_view, - precipitation_view, - reaction_rate_view, - solubility_view, - titration_view, - virtual_lab_home, -) - -app_name = "virtual_lab" - -urlpatterns = [ - path("", virtual_lab_home, name="virtual_lab_home"), - path("physics/pendulum/", physics_pendulum_view, name="physics_pendulum"), - path("physics/projectile/", physics_projectile_view, name="physics_projectile"), - path("physics/inclined/", physics_inclined_view, name="physics_inclined"), - path("physics/mass_spring/", physics_mass_spring_view, name="physics_mass_spring"), - path("physics/circuit/", physics_electrical_circuit_view, name="physics_electrical_circuit"), - path("virtual_lab/chemistry/", chemistry_home, name="chemistry_home"), - path("virtual_lab/chemistry/titration/", titration_view, name="titration"), - path("virtual_lab/chemistry/reaction-rate/", reaction_rate_view, name="reaction_rate"), - path("virtual_lab/chemistry/solubility/", solubility_view, name="solubility"), - path("virtual_lab/chemistry/precipitation/", precipitation_view, name="precipitation"), - path("virtual_lab/chemistry/ph-indicator/", ph_indicator_view, name="ph_indicator"), - path("code-editor/", code_editor_view, name="code_editor"), - path("evaluate-code/", evaluate_code, name="evaluate_code"), -] diff --git a/web/virtual_lab/views.py b/web/virtual_lab/views.py deleted file mode 100644 index cee652a..0000000 --- a/web/virtual_lab/views.py +++ /dev/null @@ -1,136 +0,0 @@ -# web/virtual_lab/views.py - -import json -import logging - -import requests -from django.http import JsonResponse -from django.shortcuts import render -from django.views.decorators.http import require_POST - -logger = logging.getLogger(__name__) - - -def virtual_lab_home(request): - """ - Renders the Virtual Lab home page (home.html). - """ - return render(request, "virtual_lab/home.html") - - -def physics_pendulum_view(request): - """ - Renders the Pendulum Motion simulation page (physics/pendulum.html). - """ - return render(request, "virtual_lab/physics/pendulum.html") - - -def physics_projectile_view(request): - """ - Renders the Projectile Motion simulation page (physics/projectile.html). - """ - return render(request, "virtual_lab/physics/projectile.html") - - -def physics_inclined_view(request): - """ - Renders the Inclined Plane simulation page (physics/inclined.html). - """ - return render(request, "virtual_lab/physics/inclined.html") - - -def physics_mass_spring_view(request): - """ - Renders the Mass-Spring Oscillation simulation page (physics/mass_spring.html). - """ - return render(request, "virtual_lab/physics/mass_spring.html") - - -def physics_electrical_circuit_view(request): - """ - Renders the Electrical Circuit simulation page (physics/circuit.html). - """ - return render(request, "virtual_lab/physics/circuit.html") - - -def chemistry_home(request): - return render(request, "virtual_lab/chemistry/index.html") - - -def titration_view(request): - return render(request, "virtual_lab/chemistry/titration.html") - - -def reaction_rate_view(request): - return render(request, "virtual_lab/chemistry/reaction_rate.html") - - -def solubility_view(request): - return render(request, "virtual_lab/chemistry/solubility.html") - - -def precipitation_view(request): - return render(request, "virtual_lab/chemistry/precipitation.html") - - -def ph_indicator_view(request): - return render(request, "virtual_lab/chemistry/ph_indicator.html") - - -# Piston’s public execute endpoint (rate-limited to 5 req/s) :contentReference[oaicite:0]{index=0} -PISTON_EXECUTE_URL = "https://emkc.org/api/v2/piston/execute" - -LANG_FILE_EXT = { - "python": "py", - "javascript": "js", - "c": "c", - "cpp": "cpp", -} - - -def code_editor_view(request): - return render(request, "virtual_lab/code_editor/code_editor.html") - - -@require_POST -def evaluate_code(request): - """ - Proxy code + stdin to Piston and return its JSON result. - """ - data = json.loads(request.body) - source_code = data.get("code", "") - language = data.get("language", "python") # e.g. "python","javascript","c","cpp" - stdin_text = data.get("stdin", "") - - # Package content for Piston - ext = LANG_FILE_EXT.get(language, "txt") - files = [{"name": f"main.{ext}", "content": source_code}] - payload = { - "language": language, - "version": "*", # semver selector; '*' picks latest :contentReference[oaicite:1]{index=1} - "files": files, - "stdin": stdin_text, - "args": [], - } - - try: - resp = requests.post(PISTON_EXECUTE_URL, json=payload, timeout=10) - resp.raise_for_status() - except requests.RequestException: - # Log the full details for your own troubleshooting - logger.exception("Failed to call Piston execute endpoint") - # Return a safe, generic message to the user - return JsonResponse( - {"stderr": "Code execution service is currently unavailable. Please try again later."}, status=502 - ) - - result = resp.json() - # Piston returns a structure like: - # { language, version, run: { stdout, stderr, code, signal, output } } - run = result.get("run", {}) - return JsonResponse( - { - "stdout": run.get("stdout", run.get("output", "")), - "stderr": run.get("stderr", ""), - } - ) diff --git a/web/wsgi.py b/web/wsgi.py deleted file mode 100644 index edfa214..0000000 --- a/web/wsgi.py +++ /dev/null @@ -1,5 +0,0 @@ -import os -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'web.settings') -application = get_wsgi_application()