From a2bb3f78d845c41853496de7e410ae4924ada556 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Fri, 3 Apr 2026 09:54:10 +0200 Subject: [PATCH 1/5] refactor: split views, reorganize CSS, add docs for new organizers --- .claude/settings.json | 16 + README.md | 5 +- app/static/css/bootstrap-overrides/alerts.css | 63 + .../css/bootstrap-overrides/buttons.css | 67 ++ .../css/bootstrap-overrides/dropdowns.css | 25 + app/static/css/bootstrap-overrides/forms.css | 95 ++ .../css/bootstrap-overrides/glyphicons.css | 262 +++++ app/static/css/bootstrap-overrides/grid.css | 226 ++++ app/static/css/bootstrap-overrides/misc.css | 98 ++ app/static/css/bootstrap-overrides/modals.css | 238 ++++ app/static/css/bootstrap-overrides/nav.css | 249 ++++ app/static/css/bootstrap-overrides/print.css | 19 + .../css/bootstrap-overrides/scaffolding.css | 16 + app/static/css/bootstrap-overrides/tables.css | 32 + .../css/bootstrap-overrides/typography.css | 83 ++ .../css/bootstrap-overrides/variables.css | 38 + app/static/css/main.css | 35 +- app/templates/base.html | 15 +- applications/views/__init__.py | 23 + applications/views/draft.py | 26 + applications/{views.py => views/hacker.py} | 145 +-- applications/views/mentor.py | 23 + applications/views/sponsor.py | 104 ++ baggage/views/__init__.py | 22 + baggage/views/hacker.py | 27 + baggage/{views.py => views/volunteer.py} | 50 +- docs/contributing.md | 87 ++ docs/email-templates.md | 88 ++ docs/getting-started.md | 80 ++ docs/setting-up.md | 87 ++ .../plans/2026-04-02-codebase-refactor.md | 1046 +++++++++++++++++ .../2026-04-02-codebase-refactor-design.md | 192 +++ hardware/views/__init__.py | 7 + hardware/{views.py => views/admin.py} | 78 +- hardware/views/hacker.py | 84 ++ organizers/static/css/organizers.css | 18 + organizers/templates/review_resume.html | 1 + organizers/views/__init__.py | 42 + organizers/views/batch_ops.py | 140 +++ organizers/views/lists.py | 401 +++++++ organizers/{views.py => views/review.py} | 478 +------- reimbursement/views/__init__.py | 13 + reimbursement/views/hacker.py | 83 ++ .../{views.py => views/organizer.py} | 81 +- stats/static/css/stats.css | 15 + stats/templates/c3_base.html | 1 + stats/templates/c3_table_base.html | 1 + user/views/__init__.py | 15 + user/{views.py => views/authentication.py} | 42 +- user/views/profile.py | 48 + 50 files changed, 4246 insertions(+), 884 deletions(-) create mode 100644 .claude/settings.json create mode 100644 app/static/css/bootstrap-overrides/alerts.css create mode 100644 app/static/css/bootstrap-overrides/buttons.css create mode 100644 app/static/css/bootstrap-overrides/dropdowns.css create mode 100644 app/static/css/bootstrap-overrides/forms.css create mode 100644 app/static/css/bootstrap-overrides/glyphicons.css create mode 100644 app/static/css/bootstrap-overrides/grid.css create mode 100644 app/static/css/bootstrap-overrides/misc.css create mode 100644 app/static/css/bootstrap-overrides/modals.css create mode 100644 app/static/css/bootstrap-overrides/nav.css create mode 100644 app/static/css/bootstrap-overrides/print.css create mode 100644 app/static/css/bootstrap-overrides/scaffolding.css create mode 100644 app/static/css/bootstrap-overrides/tables.css create mode 100644 app/static/css/bootstrap-overrides/typography.css create mode 100644 app/static/css/bootstrap-overrides/variables.css create mode 100644 applications/views/__init__.py create mode 100644 applications/views/draft.py rename applications/{views.py => views/hacker.py} (70%) create mode 100644 applications/views/mentor.py create mode 100644 applications/views/sponsor.py create mode 100644 baggage/views/__init__.py create mode 100644 baggage/views/hacker.py rename baggage/{views.py => views/volunteer.py} (93%) create mode 100644 docs/contributing.md create mode 100644 docs/email-templates.md create mode 100644 docs/getting-started.md create mode 100644 docs/setting-up.md create mode 100644 docs/superpowers/plans/2026-04-02-codebase-refactor.md create mode 100644 docs/superpowers/specs/2026-04-02-codebase-refactor-design.md create mode 100644 hardware/views/__init__.py rename hardware/{views.py => views/admin.py} (74%) create mode 100644 hardware/views/hacker.py create mode 100644 organizers/static/css/organizers.css create mode 100644 organizers/views/__init__.py create mode 100644 organizers/views/batch_ops.py create mode 100644 organizers/views/lists.py rename organizers/{views.py => views/review.py} (61%) create mode 100644 reimbursement/views/__init__.py create mode 100644 reimbursement/views/hacker.py rename reimbursement/{views.py => views/organizer.py} (77%) create mode 100644 stats/static/css/stats.css create mode 100644 user/views/__init__.py rename user/{views.py => views/authentication.py} (87%) create mode 100644 user/views/profile.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..7bf07a44a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(wc -l /Users/gerardmadridmiro/self/hackupc/myhackupc/app/static/css/*.css)", + "Bash(wc -l /Users/gerardmadridmiro/self/hackupc/myhackupc/applications/models/*.py)", + "Bash(ls /Users/gerardmadridmiro/self/hackupc/myhackupc/app/hackathon_variables*)", + "Read(//Users/gerardmadridmiro/self/hackupc/myhackupc/**)", + "Read(//Users/gerardmadridmiro/self/hackupc/myhackupc/=== hardware_user_email.html veil/**)", + "Bash(python manage.py check)", + "Bash(python3 manage.py check)", + "Bash(env/bin/python manage.py check)", + "Bash(ls /Users/gerardmadridmiro/self/hackupc/myhackupc/baggage/views*)", + "Bash(ls /Users/gerardmadridmiro/self/hackupc/myhackupc/hardware/views*)" + ] + } +} diff --git a/README.md b/README.md index 21836c366..f873d7403 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ ## Setup +> For a step-by-step local setup guide see [docs/setting-up.md](docs/setting-up.md). For codebase orientation see [docs/getting-started.md](docs/getting-started.md). + Needs: Python 3.X, virtualenv Stable at v. 3.10.X @@ -36,7 +38,8 @@ Stable at v. 3.10.X - `virtualenv env --python=python3.10` - `source ./env/bin/activate` - `pip install -r requirements.txt` -- (Optional) If using Postgres, set up the necessary environment variables for its usage before this step +- Possible error: psycopg2-binary --> install openssl@3 and export LDFLAGS, CPPFLAGS and PKG_CONFIG_PATH +- (Optional) If using Postgres, set up the necessary environment variables for its usage before next step. Install libpq and add it to PATH. - `python manage.py migrate` - `python manage.py createsuperuser` (creates super user to manage all the app) diff --git a/app/static/css/bootstrap-overrides/alerts.css b/app/static/css/bootstrap-overrides/alerts.css new file mode 100644 index 000000000..8385387d0 --- /dev/null +++ b/app/static/css/bootstrap-overrides/alerts.css @@ -0,0 +1,63 @@ +/* Bootstrap 3 overrides — alerts */ +.label{display:inline;padding:.2em .6em .3em;font-size:75%;line-height:1;color:#fff;border-radius:.25em} +.badge,.progress-bar,.tooltip{font-size:12px} +a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer} +.label:empty{display:none} +.btn .label{position:relative;top:-1px} +.label-default{background-color:#777} +.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e} +.label-primary{background-color:#5B5340} +.label-primary[href]:focus,.label-primary[href]:hover{background-color:#231F20} +.label-success{background-color:#178c54} +.label-success[href]:focus,.label-success[href]:hover{background-color:#10603a} +.label-info{background-color:#42a2cb} +.label-info[href]:focus,.label-info[href]:hover{background-color:#2f86ab} +.label-warning{background-color:#e88035} +.label-warning[href]:focus,.label-warning[href]:hover{background-color:#d26618} +.label-danger{background-color:#e18282} +.label-danger[href]:focus,.label-danger[href]:hover{background-color:#d75959} +.badge{display:inline-block;min-width:10px;padding:3px 7px;line-height:1;color:#fff;vertical-align:middle;background-color:#777;border-radius:8px} +.badge:empty{display:none} +.media-object,.thumbnail{display:block} +.btn .badge{position:relative;top:-1px} +.btn-group-xs>.btn .badge,.btn-xs .badge{top:0;padding:1px 5px} +a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer} +.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#5B5340;background-color:#fff} +.jumbotron,.jumbotron .h1,.jumbotron h1{color:inherit} +.list-group-item>.badge{float:right} +.list-group-item>.badge+.badge{margin-right:5px} +.nav-pills>li>a>.badge{margin-left:3px} +.jumbotron{padding-top:30px;padding-bottom:30px;margin-bottom:30px;background-color:#eee} +.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200} +.alert .alert-link,.close{font-weight:700} +.alert,.progress,.thumbnail{margin-bottom:20px} +.jumbotron>hr{border-top-color:#d5d5d5} +.container .jumbotron,.container-fluid .jumbotron{padding-right:15px;padding-left:15px;border-radius:8px} +.jumbotron .container{max-width:100%} +@media screen and (min-width:768px){.jumbotron{padding-top:48px;padding-bottom:48px} +.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px} +.jumbotron .h1,.jumbotron h1{font-size:63px} +} +.thumbnail{padding:4px;line-height:1.42857143;background-color:#fff0;border:1px solid #ddd;border-radius:8px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out} +.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto} +a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#5B5340} +.thumbnail .caption{padding:9px;color:#333} +.alert,.panel-body{padding:15px} +.alert{border:1px solid transparent;border-radius:8px} +.alert h4{margin-top:0;color:inherit} +.alert>p+p,.panel-group .panel+.panel{margin-top:5px} +.alert>p,.alert>ul{margin-bottom:0} +.alert-dismissable,.alert-dismissible{padding-right:35px} +.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit} +.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6} +.alert-success hr{border-top-color:#c9e2b3} +.alert-success .alert-link{color:#2b542c} +.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1} +.alert-info hr{border-top-color:#a6e1ec} +.alert-info .alert-link{color:#245269} +.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc} +.alert-warning hr{border-top-color:#f7e1b5} +.alert-warning .alert-link{color:#66512c} +.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1} +.alert-danger hr{border-top-color:#e4b9c0} +.alert-danger .alert-link{color:#843534} diff --git a/app/static/css/bootstrap-overrides/buttons.css b/app/static/css/bootstrap-overrides/buttons.css new file mode 100644 index 000000000..7ac53447e --- /dev/null +++ b/app/static/css/bootstrap-overrides/buttons.css @@ -0,0 +1,67 @@ +/* Bootstrap 3 overrides — buttons */ +.btn{display:inline-block;margin-bottom:0;white-space:nowrap;vertical-align:middle;touch-action:manipulation;cursor:pointer;background-image:none;border:1px solid transparent;padding:6px 12px;font-size:14px;border-radius:8px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none} +.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:-webkit-focus-ring-color auto 5px;outline-offset:-2px} +.btn.focus,.btn:focus,.btn:hover{color:#231F20;text-decoration:none} +.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)} +.btn.disabled,.btn[disabled],fieldset[disabled] .btn{cursor:not-allowed;filter:alpha(opacity=65);opacity:.65;-webkit-box-shadow:none;box-shadow:none} +a.btn.disabled,fieldset[disabled] a.btn{pointer-events:none} +.btn-default{color:#fff;border-color:#5B5340} +.btn-default.focus,.btn-default:focus{color:#fff;background-color:#231F20;border-color:#231F20} +.btn-default:hover{color:#fff;background-color:#231F20;border-color:#231F20} +.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{color:#fff;background-color:#231F20;background-image:none;border-color:#231F20} +.btn-default.active.focus,.btn-default.active:focus,.btn-default.active:hover,.btn-default:active.focus,.btn-default:active:focus,.btn-default:active:hover,.open>.dropdown-toggle.btn-default.focus,.open>.dropdown-toggle.btn-default:focus,.open>.dropdown-toggle.btn-default:hover{color:#fff;background-color:#231F20;border-color:#231F20} +.btn-default.disabled.focus,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled].focus,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#5B5340;border-color:#5B5340} +.btn-default .badge{color:#5B5340;background-color:#fff} +.btn-primary{color:#fff;background-color:#002b56;border-color:#002b56} +.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#231F20;border-color:#231F20} +.btn-primary:hover{color:#fff;background-color:#231F20;border-color:#231F20} +.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#231F20;background-image:none;border-color:#231F20} +.btn-primary.active.focus,.btn-primary.active:focus,.btn-primary.active:hover,.btn-primary:active.focus,.btn-primary:active:focus,.btn-primary:active:hover,.open>.dropdown-toggle.btn-primary.focus,.open>.dropdown-toggle.btn-primary:focus,.open>.dropdown-toggle.btn-primary:hover{color:#fff;background-color:#231F20;border-color:#231F20} +.btn-primary.disabled.focus,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled].focus,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#002b56;border-color:#002b56} +.btn-primary .badge{color:#5B5340;background-color:#fff} +.btn-success{color:#fff;background-color:#178c54;border-color:#137647} +.btn-success.focus,.btn-success:focus{color:#fff;background-color:#10603a;border-color:#010905} +.btn-success:hover{color:#fff;background-color:#10603a;border-color:#0b4227} +.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#10603a;background-image:none;border-color:#0b4227} +.btn-success.active.focus,.btn-success.active:focus,.btn-success.active:hover,.btn-success:active.focus,.btn-success:active:focus,.btn-success:active:hover,.open>.dropdown-toggle.btn-success.focus,.open>.dropdown-toggle.btn-success:focus,.open>.dropdown-toggle.btn-success:hover{color:#fff;background-color:#0b4227;border-color:#010905} +.btn-success.disabled.focus,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled].focus,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#178c54;border-color:#137647} +.btn-success .badge{color:#178c54;background-color:#fff} +.btn-info{color:#fff;background-color:#42a2cb;border-color:#3596bf} +.btn-info.focus,.btn-info:focus{color:#fff;background-color:#2f86ab;border-color:#19475b} +.btn-info:hover{color:#fff;background-color:#2f86ab;border-color:#27708f} +.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#2f86ab;background-image:none;border-color:#27708f} +.btn-info.active.focus,.btn-info.active:focus,.btn-info.active:hover,.btn-info:active.focus,.btn-info:active:focus,.btn-info:active:hover,.open>.dropdown-toggle.btn-info.focus,.open>.dropdown-toggle.btn-info:focus,.open>.dropdown-toggle.btn-info:hover{color:#fff;background-color:#27708f;border-color:#19475b} +.btn-info.disabled.focus,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled].focus,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#42a2cb;border-color:#3596bf} +.btn-info .badge{color:#42a2cb;background-color:#fff} +.btn-warning{color:#fff;background-color:#e88035;border-color:#e5721e} +.btn-warning.focus,.btn-warning:focus{color:#fff;background-color:#d26618;border-color:#77390d} +.btn-warning:hover{color:#fff;background-color:#d26618;border-color:#b25614} +.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#d26618;background-image:none;border-color:#b25614} +.btn-warning.active.focus,.btn-warning.active:focus,.btn-warning.active:hover,.btn-warning:active.focus,.btn-warning:active:focus,.btn-warning:active:hover,.open>.dropdown-toggle.btn-warning.focus,.open>.dropdown-toggle.btn-warning:focus,.open>.dropdown-toggle.btn-warning:hover{color:#fff;background-color:#b25614;border-color:#77390d} +.btn-warning.disabled.focus,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled].focus,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#e88035;border-color:#e5721e} +.btn-warning .badge{color:#e88035;background-color:#fff} +.btn-danger{color:#fff;background-color:#e18282;border-color:#dc6d6d} +.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#d75959;border-color:#a32727} +.btn-danger:hover{color:#fff;background-color:#d75959;border-color:#d03c3c} +.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#d75959;background-image:none;border-color:#d03c3c} +.btn-danger.active.focus,.btn-danger.active:focus,.btn-danger.active:hover,.btn-danger:active.focus,.btn-danger:active:focus,.btn-danger:active:hover,.open>.dropdown-toggle.btn-danger.focus,.open>.dropdown-toggle.btn-danger:focus,.open>.dropdown-toggle.btn-danger:hover{color:#fff;background-color:#d03c3c;border-color:#a32727} +.btn-danger.disabled.focus,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled].focus,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#e18282;border-color:#dc6d6d} +.btn-danger .badge{color:#e18282;background-color:#fff} +.btn-link{font-weight:400;color:#5B5340;border-radius:0} +.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none} +.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent} +.btn-link:focus,.btn-link:hover{color:#231F20;text-decoration:underline;background-color:transparent} +.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none} +.btn-group-lg>.btn,.btn-lg{line-height:1.3333333;border-radius:8px} +.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:8px} +.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:8px} +.btn-block{display:block;width:100%;margin-top:5px} +.btn-block+.btn-block{margin-top:5px} +input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%} +.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear} +.fade.in{opacity:1} +.collapse{display:none} +.collapse.in{display:block} +tr.collapse.in{display:table-row} +tbody.collapse.in{display:table-row-group} +.collapsing{height:0;overflow:hidden;-webkit-transition-property:height,visibility;transition-property:height,visibility;-webkit-transition-duration:.35s;transition-duration:.35s;-webkit-transition-timing-function:ease;transition-timing-function:ease} diff --git a/app/static/css/bootstrap-overrides/dropdowns.css b/app/static/css/bootstrap-overrides/dropdowns.css new file mode 100644 index 000000000..5cae97862 --- /dev/null +++ b/app/static/css/bootstrap-overrides/dropdowns.css @@ -0,0 +1,25 @@ +/* Bootstrap 3 overrides — dropdowns */ +.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px dashed;border-top:4px solid\9;border-right:4px solid transparent;border-left:4px solid transparent} +.dropdown-toggle:focus{outline:0} +.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:8px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)} +.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child,.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0} +.btn-group-vertical>.btn:not(:first-child):not(:last-child),.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn,.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0} +.dropdown-header,.dropdown-menu>li>a{white-space:nowrap;padding:3px 20px;line-height:1.42857143} +.dropdown-menu-right,.dropdown-menu.pull-right{right:0;left:auto} +.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5} +.dropdown-menu>li>a{display:block;clear:both;font-weight:400;color:#333} +.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5} +.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#5B5340;outline:0} +.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777} +.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)} +.open>.dropdown-menu{display:block} +.open>a{outline:0} +.dropdown-menu-left{right:auto;left:0} +.dropdown-header{display:block;font-size:12px;color:#777} +.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990} +.pull-right>.dropdown-menu{right:0;left:auto} +.dropup .caret,.navbar-fixed-bottom .dropdown .caret{border-top:0;border-bottom:4px dashed;border-bottom:4px solid\9} +.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px} +@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto} +.navbar-right .dropdown-menu-left{right:auto;left:0} +} diff --git a/app/static/css/bootstrap-overrides/forms.css b/app/static/css/bootstrap-overrides/forms.css new file mode 100644 index 000000000..bf76fafab --- /dev/null +++ b/app/static/css/bootstrap-overrides/forms.css @@ -0,0 +1,95 @@ +/* Bootstrap 3 overrides — forms */ +fieldset,legend{padding:0;border:0} +fieldset{min-width:0;margin:0} +legend{display:block;width:100%;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border-bottom:1px solid #e5e5e5} +.form-control,output{color:#555;font-size:14px} +label{display:inline-block;max-width:100%;margin-bottom:5px} +input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;-webkit-appearance:none;appearance:none} +input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px\9;line-height:normal} +.btn,.form-control,output{line-height:1.42857143} +input[type=file]{display:block} +input[type=range]{display:block;width:100%} +select[multiple],select[size]{height:auto} +input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:-webkit-focus-ring-color auto 5px;outline-offset:-2px} +output{display:block;padding-top:7px} +.form-control{display:block;width:100%;height:34px;padding:6px 12px;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:8px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s} +.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)} +.form-control::-moz-placeholder{color:#999;opacity:1} +.form-control:-ms-input-placeholder{color:#999} +.form-control::-webkit-input-placeholder{color:#999} +.form-control::-ms-expand{background-color:transparent;border:0} +.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{background-color:#eee;opacity:1} +textarea.form-control{height:auto} +@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date].form-control,input[type=time].form-control,input[type=datetime-local].form-control,input[type=month].form-control{line-height:34px} +.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px} +.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px} +} +.form-group{margin-bottom:15px} +.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px} +.checkbox label,.checkbox-inline,.radio label,.radio-inline{margin-bottom:0;padding-left:20px;font-weight:400;cursor:pointer} +.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px\9;margin-left:-20px} +.checkbox-inline,.collapsing,.dropdown,.dropup,.has-feedback,.radio-inline{position:relative} +.checkbox+.checkbox,.radio+.radio{margin-top:-5px} +.checkbox-inline,.radio-inline{display:inline-block;vertical-align:middle} +.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed} +.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px} +.form-control-static{min-height:34px;padding-top:7px;padding-bottom:7px;margin-bottom:0} +.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0} +.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:8px} +select.input-sm{height:30px;line-height:30px} +select[multiple].input-sm,textarea.input-sm{height:auto} +.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:8px} +.form-group-sm select.form-control{height:30px;line-height:30px} +.form-group-sm select[multiple].form-control,.form-group-sm textarea.form-control{height:auto} +.form-group-sm .form-control-static{height:30px;min-height:32px;padding:6px 10px;font-size:12px;line-height:1.5} +.btn-group-lg>.btn,.btn-lg,.form-group-lg .form-control,.input-lg{padding:10px 16px;font-size:18px} +.input-lg{height:46px;line-height:1.3333333;border-radius:8px} +select.input-lg{height:46px;line-height:46px} +select[multiple].input-lg,textarea.input-lg{height:auto} +.form-group-lg .form-control{height:46px;line-height:1.3333333;border-radius:8px} +.form-group-lg select.form-control{height:46px;line-height:46px} +.form-group-lg select[multiple].form-control,.form-group-lg textarea.form-control{height:auto} +.form-group-lg .form-control-static{height:46px;min-height:38px;padding:11px 16px;font-size:18px;line-height:1.3333333} +.has-feedback .form-control{padding-right:42.5px} +.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none} +.form-group-lg .form-control+.form-control-feedback,.input-group-lg+.form-control-feedback,.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px} +.form-group-sm .form-control+.form-control-feedback,.input-group-sm+.form-control-feedback,.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px} +.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d} +.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)} +.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168} +.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d} +.has-success .form-control-feedback{color:#3c763d} +.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b} +.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)} +.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b} +.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b} +.has-warning .form-control-feedback{color:#8a6d3b} +.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442} +.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)} +.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483} +.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442} +.has-error .form-control-feedback{color:#a94442} +.has-feedback label~.form-control-feedback{top:25px} +.has-feedback label.sr-only~.form-control-feedback{top:0} +.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373} +@media (min-width:768px){.form-inline .form-control,.form-inline .form-control-static,.form-inline .form-group{display:inline-block} +.form-inline .checkbox,.form-inline .control-label,.form-inline .form-group,.form-inline .radio{margin-bottom:0;vertical-align:middle} +.form-inline .form-control{width:auto;vertical-align:middle} +.form-inline .input-group{display:inline-table;vertical-align:middle} +.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto} +.form-inline .input-group>.form-control{width:100%} +.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0} +.form-inline .checkbox label,.form-inline .radio label{padding-left:0} +.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0} +.form-inline .has-feedback .form-control-feedback{top:0} +} +.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0} +.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px} +.form-horizontal .form-group{margin-right:-15px;margin-left:-15px} +@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right} +} +.btn,.input-group-addon{font-weight:400;text-align:center} +.form-horizontal .has-feedback .form-control-feedback{right:15px} +@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:11px;font-size:18px} +.form-horizontal .form-group-sm .control-label{padding-top:6px;font-size:12px} +} diff --git a/app/static/css/bootstrap-overrides/glyphicons.css b/app/static/css/bootstrap-overrides/glyphicons.css new file mode 100644 index 000000000..98d5bdbd4 --- /dev/null +++ b/app/static/css/bootstrap-overrides/glyphicons.css @@ -0,0 +1,262 @@ +/* Bootstrap 3 overrides — glyphicons */ +.glyphicon,.img-thumbnail,.list-inline>li{display:inline-block} +.glyphicon{position:relative;top:1px;font-family:"Glyphicons Halflings";font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} +.img-thumbnail,body{background-color:#fff0;line-height:1.42857143} +.glyphicon-asterisk:before{content:"\002a"} +.glyphicon-plus:before{content:"\002b"} +.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"} +.glyphicon-minus:before{content:"\2212"} +.glyphicon-cloud:before{content:"\2601"} +.glyphicon-envelope:before{content:"\2709"} +.glyphicon-pencil:before{content:"\270f"} +.glyphicon-glass:before{content:"\e001"} +.glyphicon-music:before{content:"\e002"} +.glyphicon-search:before{content:"\e003"} +.glyphicon-heart:before{content:"\e005"} +.glyphicon-star:before{content:"\e006"} +.glyphicon-star-empty:before{content:"\e007"} +.glyphicon-user:before{content:"\e008"} +.glyphicon-film:before{content:"\e009"} +.glyphicon-th-large:before{content:"\e010"} +.glyphicon-th:before{content:"\e011"} +.glyphicon-th-list:before{content:"\e012"} +.glyphicon-ok:before{content:"\e013"} +.glyphicon-remove:before{content:"\e014"} +.glyphicon-zoom-in:before{content:"\e015"} +.glyphicon-zoom-out:before{content:"\e016"} +.glyphicon-off:before{content:"\e017"} +.glyphicon-signal:before{content:"\e018"} +.glyphicon-cog:before{content:"\e019"} +.glyphicon-trash:before{content:"\e020"} +.glyphicon-home:before{content:"\e021"} +.glyphicon-file:before{content:"\e022"} +.glyphicon-time:before{content:"\e023"} +.glyphicon-road:before{content:"\e024"} +.glyphicon-download-alt:before{content:"\e025"} +.glyphicon-download:before{content:"\e026"} +.glyphicon-upload:before{content:"\e027"} +.glyphicon-inbox:before{content:"\e028"} +.glyphicon-play-circle:before{content:"\e029"} +.glyphicon-repeat:before{content:"\e030"} +.glyphicon-refresh:before{content:"\e031"} +.glyphicon-list-alt:before{content:"\e032"} +.glyphicon-lock:before{content:"\e033"} +.glyphicon-flag:before{content:"\e034"} +.glyphicon-headphones:before{content:"\e035"} +.glyphicon-volume-off:before{content:"\e036"} +.glyphicon-volume-down:before{content:"\e037"} +.glyphicon-volume-up:before{content:"\e038"} +.glyphicon-qrcode:before{content:"\e039"} +.glyphicon-barcode:before{content:"\e040"} +.glyphicon-tag:before{content:"\e041"} +.glyphicon-tags:before{content:"\e042"} +.glyphicon-book:before{content:"\e043"} +.glyphicon-bookmark:before{content:"\e044"} +.glyphicon-print:before{content:"\e045"} +.glyphicon-camera:before{content:"\e046"} +.glyphicon-font:before{content:"\e047"} +.glyphicon-bold:before{content:"\e048"} +.glyphicon-italic:before{content:"\e049"} +.glyphicon-text-height:before{content:"\e050"} +.glyphicon-text-width:before{content:"\e051"} +.glyphicon-align-left:before{content:"\e052"} +.glyphicon-align-center:before{content:"\e053"} +.glyphicon-align-right:before{content:"\e054"} +.glyphicon-align-justify:before{content:"\e055"} +.glyphicon-list:before{content:"\e056"} +.glyphicon-indent-left:before{content:"\e057"} +.glyphicon-indent-right:before{content:"\e058"} +.glyphicon-facetime-video:before{content:"\e059"} +.glyphicon-picture:before{content:"\e060"} +.glyphicon-map-marker:before{content:"\e062"} +.glyphicon-adjust:before{content:"\e063"} +.glyphicon-tint:before{content:"\e064"} +.glyphicon-edit:before{content:"\e065"} +.glyphicon-share:before{content:"\e066"} +.glyphicon-check:before{content:"\e067"} +.glyphicon-move:before{content:"\e068"} +.glyphicon-step-backward:before{content:"\e069"} +.glyphicon-fast-backward:before{content:"\e070"} +.glyphicon-backward:before{content:"\e071"} +.glyphicon-play:before{content:"\e072"} +.glyphicon-pause:before{content:"\e073"} +.glyphicon-stop:before{content:"\e074"} +.glyphicon-forward:before{content:"\e075"} +.glyphicon-fast-forward:before{content:"\e076"} +.glyphicon-step-forward:before{content:"\e077"} +.glyphicon-eject:before{content:"\e078"} +.glyphicon-chevron-left:before{content:"\e079"} +.glyphicon-chevron-right:before{content:"\e080"} +.glyphicon-plus-sign:before{content:"\e081"} +.glyphicon-minus-sign:before{content:"\e082"} +.glyphicon-remove-sign:before{content:"\e083"} +.glyphicon-ok-sign:before{content:"\e084"} +.glyphicon-question-sign:before{content:"\e085"} +.glyphicon-info-sign:before{content:"\e086"} +.glyphicon-screenshot:before{content:"\e087"} +.glyphicon-remove-circle:before{content:"\e088"} +.glyphicon-ok-circle:before{content:"\e089"} +.glyphicon-ban-circle:before{content:"\e090"} +.glyphicon-arrow-left:before{content:"\e091"} +.glyphicon-arrow-right:before{content:"\e092"} +.glyphicon-arrow-up:before{content:"\e093"} +.glyphicon-arrow-down:before{content:"\e094"} +.glyphicon-share-alt:before{content:"\e095"} +.glyphicon-resize-full:before{content:"\e096"} +.glyphicon-resize-small:before{content:"\e097"} +.glyphicon-exclamation-sign:before{content:"\e101"} +.glyphicon-gift:before{content:"\e102"} +.glyphicon-leaf:before{content:"\e103"} +.glyphicon-fire:before{content:"\e104"} +.glyphicon-eye-open:before{content:"\e105"} +.glyphicon-eye-close:before{content:"\e106"} +.glyphicon-warning-sign:before{content:"\e107"} +.glyphicon-plane:before{content:"\e108"} +.glyphicon-calendar:before{content:"\e109"} +.glyphicon-random:before{content:"\e110"} +.glyphicon-comment:before{content:"\e111"} +.glyphicon-magnet:before{content:"\e112"} +.glyphicon-chevron-up:before{content:"\e113"} +.glyphicon-chevron-down:before{content:"\e114"} +.glyphicon-retweet:before{content:"\e115"} +.glyphicon-shopping-cart:before{content:"\e116"} +.glyphicon-folder-close:before{content:"\e117"} +.glyphicon-folder-open:before{content:"\e118"} +.glyphicon-resize-vertical:before{content:"\e119"} +.glyphicon-resize-horizontal:before{content:"\e120"} +.glyphicon-hdd:before{content:"\e121"} +.glyphicon-bullhorn:before{content:"\e122"} +.glyphicon-bell:before{content:"\e123"} +.glyphicon-certificate:before{content:"\e124"} +.glyphicon-thumbs-up:before{content:"\e125"} +.glyphicon-thumbs-down:before{content:"\e126"} +.glyphicon-hand-right:before{content:"\e127"} +.glyphicon-hand-left:before{content:"\e128"} +.glyphicon-hand-up:before{content:"\e129"} +.glyphicon-hand-down:before{content:"\e130"} +.glyphicon-circle-arrow-right:before{content:"\e131"} +.glyphicon-circle-arrow-left:before{content:"\e132"} +.glyphicon-circle-arrow-up:before{content:"\e133"} +.glyphicon-circle-arrow-down:before{content:"\e134"} +.glyphicon-globe:before{content:"\e135"} +.glyphicon-wrench:before{content:"\e136"} +.glyphicon-tasks:before{content:"\e137"} +.glyphicon-filter:before{content:"\e138"} +.glyphicon-briefcase:before{content:"\e139"} +.glyphicon-fullscreen:before{content:"\e140"} +.glyphicon-dashboard:before{content:"\e141"} +.glyphicon-paperclip:before{content:"\e142"} +.glyphicon-heart-empty:before{content:"\e143"} +.glyphicon-link:before{content:"\e144"} +.glyphicon-phone:before{content:"\e145"} +.glyphicon-pushpin:before{content:"\e146"} +.glyphicon-usd:before{content:"\e148"} +.glyphicon-gbp:before{content:"\e149"} +.glyphicon-sort:before{content:"\e150"} +.glyphicon-sort-by-alphabet:before{content:"\e151"} +.glyphicon-sort-by-alphabet-alt:before{content:"\e152"} +.glyphicon-sort-by-order:before{content:"\e153"} +.glyphicon-sort-by-order-alt:before{content:"\e154"} +.glyphicon-sort-by-attributes:before{content:"\e155"} +.glyphicon-sort-by-attributes-alt:before{content:"\e156"} +.glyphicon-unchecked:before{content:"\e157"} +.glyphicon-expand:before{content:"\e158"} +.glyphicon-collapse-down:before{content:"\e159"} +.glyphicon-collapse-up:before{content:"\e160"} +.glyphicon-log-in:before{content:"\e161"} +.glyphicon-flash:before{content:"\e162"} +.glyphicon-log-out:before{content:"\e163"} +.glyphicon-new-window:before{content:"\e164"} +.glyphicon-record:before{content:"\e165"} +.glyphicon-save:before{content:"\e166"} +.glyphicon-open:before{content:"\e167"} +.glyphicon-saved:before{content:"\e168"} +.glyphicon-import:before{content:"\e169"} +.glyphicon-export:before{content:"\e170"} +.glyphicon-send:before{content:"\e171"} +.glyphicon-floppy-disk:before{content:"\e172"} +.glyphicon-floppy-saved:before{content:"\e173"} +.glyphicon-floppy-remove:before{content:"\e174"} +.glyphicon-floppy-save:before{content:"\e175"} +.glyphicon-floppy-open:before{content:"\e176"} +.glyphicon-credit-card:before{content:"\e177"} +.glyphicon-transfer:before{content:"\e178"} +.glyphicon-cutlery:before{content:"\e179"} +.glyphicon-header:before{content:"\e180"} +.glyphicon-compressed:before{content:"\e181"} +.glyphicon-earphone:before{content:"\e182"} +.glyphicon-phone-alt:before{content:"\e183"} +.glyphicon-tower:before{content:"\e184"} +.glyphicon-stats:before{content:"\e185"} +.glyphicon-sd-video:before{content:"\e186"} +.glyphicon-hd-video:before{content:"\e187"} +.glyphicon-subtitles:before{content:"\e188"} +.glyphicon-sound-stereo:before{content:"\e189"} +.glyphicon-sound-dolby:before{content:"\e190"} +.glyphicon-sound-5-1:before{content:"\e191"} +.glyphicon-sound-6-1:before{content:"\e192"} +.glyphicon-sound-7-1:before{content:"\e193"} +.glyphicon-copyright-mark:before{content:"\e194"} +.glyphicon-registration-mark:before{content:"\e195"} +.glyphicon-cloud-download:before{content:"\e197"} +.glyphicon-cloud-upload:before{content:"\e198"} +.glyphicon-tree-conifer:before{content:"\e199"} +.glyphicon-tree-deciduous:before{content:"\e200"} +.glyphicon-cd:before{content:"\e201"} +.glyphicon-save-file:before{content:"\e202"} +.glyphicon-open-file:before{content:"\e203"} +.glyphicon-level-up:before{content:"\e204"} +.glyphicon-copy:before{content:"\e205"} +.glyphicon-paste:before{content:"\e206"} +.glyphicon-alert:before{content:"\e209"} +.glyphicon-equalizer:before{content:"\e210"} +.glyphicon-king:before{content:"\e211"} +.glyphicon-queen:before{content:"\e212"} +.glyphicon-pawn:before{content:"\e213"} +.glyphicon-bishop:before{content:"\e214"} +.glyphicon-knight:before{content:"\e215"} +.glyphicon-baby-formula:before{content:"\e216"} +.glyphicon-tent:before{content:"\26fa"} +.glyphicon-blackboard:before{content:"\e218"} +.glyphicon-bed:before{content:"\e219"} +.glyphicon-apple:before{content:"\f8ff"} +.glyphicon-erase:before{content:"\e221"} +.glyphicon-hourglass:before{content:"\231b"} +.glyphicon-lamp:before{content:"\e223"} +.glyphicon-duplicate:before{content:"\e224"} +.glyphicon-piggy-bank:before{content:"\e225"} +.glyphicon-scissors:before{content:"\e226"} +.glyphicon-bitcoin:before,.glyphicon-btc:before,.glyphicon-xbt:before{content:"\e227"} +.glyphicon-jpy:before,.glyphicon-yen:before{content:"\00a5"} +.glyphicon-rub:before,.glyphicon-ruble:before{content:"\20bd"} +.glyphicon-scale:before{content:"\e230"} +.glyphicon-ice-lolly:before{content:"\e231"} +.glyphicon-ice-lolly-tasted:before{content:"\e232"} +.glyphicon-education:before{content:"\e233"} +.glyphicon-option-horizontal:before{content:"\e234"} +.glyphicon-option-vertical:before{content:"\e235"} +.glyphicon-menu-hamburger:before{content:"\e236"} +.glyphicon-modal-window:before{content:"\e237"} +.glyphicon-oil:before{content:"\e238"} +.glyphicon-grain:before{content:"\e239"} +.glyphicon-sunglasses:before{content:"\e240"} +.glyphicon-text-size:before{content:"\e241"} +.glyphicon-text-color:before{content:"\e242"} +.glyphicon-text-background:before{content:"\e243"} +.glyphicon-object-align-top:before{content:"\e244"} +.glyphicon-object-align-bottom:before{content:"\e245"} +.glyphicon-object-align-horizontal:before{content:"\e246"} +.glyphicon-object-align-left:before{content:"\e247"} +.glyphicon-object-align-vertical:before{content:"\e248"} +.glyphicon-object-align-right:before{content:"\e249"} +.glyphicon-triangle-right:before{content:"\e250"} +.glyphicon-triangle-left:before{content:"\e251"} +.glyphicon-triangle-bottom:before{content:"\e252"} +.glyphicon-triangle-top:before{content:"\e253"} +.glyphicon-console:before{content:"\e254"} +.glyphicon-superscript:before{content:"\e255"} +.glyphicon-subscript:before{content:"\e256"} +.glyphicon-menu-left:before{content:"\e257"} +.glyphicon-menu-right:before{content:"\e258"} +.glyphicon-menu-down:before{content:"\e259"} +.glyphicon-menu-up:before{content:"\e260"} diff --git a/app/static/css/bootstrap-overrides/grid.css b/app/static/css/bootstrap-overrides/grid.css new file mode 100644 index 000000000..52027817d --- /dev/null +++ b/app/static/css/bootstrap-overrides/grid.css @@ -0,0 +1,226 @@ +/* Bootstrap 3 overrides — grid */ +.container,.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto} +.pre-scrollable{max-height:340px;overflow-y:scroll} +@media (min-width:768px){.container{width:750px} +} +@media (min-width:992px){.container{width:970px} +} +@media (min-width:1200px){.container{width:1170px} +} +.row{margin-right:-15px;margin-left:-15px} +.row-no-gutters{margin-right:0;margin-left:0} +.row-no-gutters [class*=col-]{padding-right:0;padding-left:0} +.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px} +.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left} +.col-xs-12{width:100%} +.col-xs-11{width:91.66666667%} +.col-xs-10{width:83.33333333%} +.col-xs-9{width:75%} +.col-xs-8{width:66.66666667%} +.col-xs-7{width:58.33333333%} +.col-xs-6{width:50%} +.col-xs-5{width:41.66666667%} +.col-xs-4{width:33.33333333%} +.col-xs-3{width:25%} +.col-xs-2{width:16.66666667%} +.col-xs-1{width:8.33333333%} +.col-xs-pull-12{right:100%} +.col-xs-pull-11{right:91.66666667%} +.col-xs-pull-10{right:83.33333333%} +.col-xs-pull-9{right:75%} +.col-xs-pull-8{right:66.66666667%} +.col-xs-pull-7{right:58.33333333%} +.col-xs-pull-6{right:50%} +.col-xs-pull-5{right:41.66666667%} +.col-xs-pull-4{right:33.33333333%} +.col-xs-pull-3{right:25%} +.col-xs-pull-2{right:16.66666667%} +.col-xs-pull-1{right:8.33333333%} +.col-xs-pull-0{right:auto} +.col-xs-push-12{left:100%} +.col-xs-push-11{left:91.66666667%} +.col-xs-push-10{left:83.33333333%} +.col-xs-push-9{left:75%} +.col-xs-push-8{left:66.66666667%} +.col-xs-push-7{left:58.33333333%} +.col-xs-push-6{left:50%} +.col-xs-push-5{left:41.66666667%} +.col-xs-push-4{left:33.33333333%} +.col-xs-push-3{left:25%} +.col-xs-push-2{left:16.66666667%} +.col-xs-push-1{left:8.33333333%} +.col-xs-push-0{left:auto} +.col-xs-offset-12{margin-left:100%} +.col-xs-offset-11{margin-left:91.66666667%} +.col-xs-offset-10{margin-left:83.33333333%} +.col-xs-offset-9{margin-left:75%} +.col-xs-offset-8{margin-left:66.66666667%} +.col-xs-offset-7{margin-left:58.33333333%} +.col-xs-offset-6{margin-left:50%} +.col-xs-offset-5{margin-left:41.66666667%} +.col-xs-offset-4{margin-left:33.33333333%} +.col-xs-offset-3{margin-left:25%} +.col-xs-offset-2{margin-left:16.66666667%} +.col-xs-offset-1{margin-left:8.33333333%} +.col-xs-offset-0{margin-left:0} +@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left} +.col-sm-12{width:100%} +.col-sm-11{width:91.66666667%} +.col-sm-10{width:83.33333333%} +.col-sm-9{width:75%} +.col-sm-8{width:66.66666667%} +.col-sm-7{width:58.33333333%} +.col-sm-6{width:50%} +.col-sm-5{width:41.66666667%} +.col-sm-4{width:33.33333333%} +.col-sm-3{width:25%} +.col-sm-2{width:16.66666667%} +.col-sm-1{width:8.33333333%} +.col-sm-pull-12{right:100%} +.col-sm-pull-11{right:91.66666667%} +.col-sm-pull-10{right:83.33333333%} +.col-sm-pull-9{right:75%} +.col-sm-pull-8{right:66.66666667%} +.col-sm-pull-7{right:58.33333333%} +.col-sm-pull-6{right:50%} +.col-sm-pull-5{right:41.66666667%} +.col-sm-pull-4{right:33.33333333%} +.col-sm-pull-3{right:25%} +.col-sm-pull-2{right:16.66666667%} +.col-sm-pull-1{right:8.33333333%} +.col-sm-pull-0{right:auto} +.col-sm-push-12{left:100%} +.col-sm-push-11{left:91.66666667%} +.col-sm-push-10{left:83.33333333%} +.col-sm-push-9{left:75%} +.col-sm-push-8{left:66.66666667%} +.col-sm-push-7{left:58.33333333%} +.col-sm-push-6{left:50%} +.col-sm-push-5{left:41.66666667%} +.col-sm-push-4{left:33.33333333%} +.col-sm-push-3{left:25%} +.col-sm-push-2{left:16.66666667%} +.col-sm-push-1{left:8.33333333%} +.col-sm-push-0{left:auto} +.col-sm-offset-12{margin-left:100%} +.col-sm-offset-11{margin-left:91.66666667%} +.col-sm-offset-10{margin-left:83.33333333%} +.col-sm-offset-9{margin-left:75%} +.col-sm-offset-8{margin-left:66.66666667%} +.col-sm-offset-7{margin-left:58.33333333%} +.col-sm-offset-6{margin-left:50%} +.col-sm-offset-5{margin-left:41.66666667%} +.col-sm-offset-4{margin-left:33.33333333%} +.col-sm-offset-3{margin-left:25%} +.col-sm-offset-2{margin-left:16.66666667%} +.col-sm-offset-1{margin-left:8.33333333%} +.col-sm-offset-0{margin-left:0} +} +@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left} +.col-md-12{width:100%} +.col-md-11{width:91.66666667%} +.col-md-10{width:83.33333333%} +.col-md-9{width:75%} +.col-md-8{width:66.66666667%} +.col-md-7{width:58.33333333%} +.col-md-6{width:50%} +.col-md-5{width:41.66666667%} +.col-md-4{width:33.33333333%} +.col-md-3{width:25%} +.col-md-2{width:16.66666667%} +.col-md-1{width:8.33333333%} +.col-md-pull-12{right:100%} +.col-md-pull-11{right:91.66666667%} +.col-md-pull-10{right:83.33333333%} +.col-md-pull-9{right:75%} +.col-md-pull-8{right:66.66666667%} +.col-md-pull-7{right:58.33333333%} +.col-md-pull-6{right:50%} +.col-md-pull-5{right:41.66666667%} +.col-md-pull-4{right:33.33333333%} +.col-md-pull-3{right:25%} +.col-md-pull-2{right:16.66666667%} +.col-md-pull-1{right:8.33333333%} +.col-md-pull-0{right:auto} +.col-md-push-12{left:100%} +.col-md-push-11{left:91.66666667%} +.col-md-push-10{left:83.33333333%} +.col-md-push-9{left:75%} +.col-md-push-8{left:66.66666667%} +.col-md-push-7{left:58.33333333%} +.col-md-push-6{left:50%} +.col-md-push-5{left:41.66666667%} +.col-md-push-4{left:33.33333333%} +.col-md-push-3{left:25%} +.col-md-push-2{left:16.66666667%} +.col-md-push-1{left:8.33333333%} +.col-md-push-0{left:auto} +.col-md-offset-12{margin-left:100%} +.col-md-offset-11{margin-left:91.66666667%} +.col-md-offset-10{margin-left:83.33333333%} +.col-md-offset-9{margin-left:75%} +.col-md-offset-8{margin-left:66.66666667%} +.col-md-offset-7{margin-left:58.33333333%} +.col-md-offset-6{margin-left:50%} +.col-md-offset-5{margin-left:41.66666667%} +.col-md-offset-4{margin-left:33.33333333%} +.col-md-offset-3{margin-left:25%} +.col-md-offset-2{margin-left:16.66666667%} +.col-md-offset-1{margin-left:8.33333333%} +.col-md-offset-0{margin-left:0} +} +@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left} +.col-lg-12{width:100%} +.col-lg-11{width:91.66666667%} +.col-lg-10{width:83.33333333%} +.col-lg-9{width:75%} +.col-lg-8{width:66.66666667%} +.col-lg-7{width:58.33333333%} +.col-lg-6{width:50%} +.col-lg-5{width:41.66666667%} +.col-lg-4{width:33.33333333%} +.col-lg-3{width:25%} +.col-lg-2{width:16.66666667%} +.col-lg-1{width:8.33333333%} +.col-lg-pull-12{right:100%} +.col-lg-pull-11{right:91.66666667%} +.col-lg-pull-10{right:83.33333333%} +.col-lg-pull-9{right:75%} +.col-lg-pull-8{right:66.66666667%} +.col-lg-pull-7{right:58.33333333%} +.col-lg-pull-6{right:50%} +.col-lg-pull-5{right:41.66666667%} +.col-lg-pull-4{right:33.33333333%} +.col-lg-pull-3{right:25%} +.col-lg-pull-2{right:16.66666667%} +.col-lg-pull-1{right:8.33333333%} +.col-lg-pull-0{right:auto} +.col-lg-push-12{left:100%} +.col-lg-push-11{left:91.66666667%} +.col-lg-push-10{left:83.33333333%} +.col-lg-push-9{left:75%} +.col-lg-push-8{left:66.66666667%} +.col-lg-push-7{left:58.33333333%} +.col-lg-push-6{left:50%} +.col-lg-push-5{left:41.66666667%} +.col-lg-push-4{left:33.33333333%} +.col-lg-push-3{left:25%} +.col-lg-push-2{left:16.66666667%} +.col-lg-push-1{left:8.33333333%} +.col-lg-push-0{left:auto} +.col-lg-offset-12{margin-left:100%} +.col-lg-offset-11{margin-left:91.66666667%} +.col-lg-offset-10{margin-left:83.33333333%} +.col-lg-offset-9{margin-left:75%} +.col-lg-offset-8{margin-left:66.66666667%} +.col-lg-offset-7{margin-left:58.33333333%} +.col-lg-offset-6{margin-left:50%} +.col-lg-offset-5{margin-left:41.66666667%} +.col-lg-offset-4{margin-left:33.33333333%} +.col-lg-offset-3{margin-left:25%} +.col-lg-offset-2{margin-left:16.66666667%} +.col-lg-offset-1{margin-left:8.33333333%} +.col-lg-offset-0{margin-left:0} +} +table col[class*=col-]{position:static;display:table-column;float:none} +table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none} diff --git a/app/static/css/bootstrap-overrides/misc.css b/app/static/css/bootstrap-overrides/misc.css new file mode 100644 index 000000000..00c7d69c3 --- /dev/null +++ b/app/static/css/bootstrap-overrides/misc.css @@ -0,0 +1,98 @@ +/* Bootstrap 3 overrides — misc */ +.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.modal-header:after,.modal-header:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "} +.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.modal-header:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both} +.center-block{display:block;margin-right:auto;margin-left:auto} +.pull-right{float:right!important} +.pull-left{float:left!important} +.hide{display:none!important} +.show{display:block!important} +.hidden,.visible-lg,.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important} +.invisible{visibility:hidden} +.text-hide{font:0/0 a;color:transparent;background-color:transparent;border:0} +.affix{position:fixed} +@-ms-viewport{width:device-width} +@media (max-width:767px){.visible-xs{display:block!important} +table.visible-xs{display:table!important} +tr.visible-xs{display:table-row!important} +td.visible-xs,th.visible-xs{display:table-cell!important} +.visible-xs-block{display:block!important} +.visible-xs-inline{display:inline!important} +.visible-xs-inline-block{display:inline-block!important} +} +@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important} +table.visible-sm{display:table!important} +tr.visible-sm{display:table-row!important} +td.visible-sm,th.visible-sm{display:table-cell!important} +.visible-sm-block{display:block!important} +.visible-sm-inline{display:inline!important} +.visible-sm-inline-block{display:inline-block!important} +} +@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important} +table.visible-md{display:table!important} +tr.visible-md{display:table-row!important} +td.visible-md,th.visible-md{display:table-cell!important} +.visible-md-block{display:block!important} +.visible-md-inline{display:inline!important} +.visible-md-inline-block{display:inline-block!important} +} +@media (min-width:1200px){.visible-lg{display:block!important} +table.visible-lg{display:table!important} +tr.visible-lg{display:table-row!important} +td.visible-lg,th.visible-lg{display:table-cell!important} +.visible-lg-block{display:block!important} +.visible-lg-inline{display:inline!important} +.visible-lg-inline-block{display:inline-block!important} +} +@media (max-width:767px){.hidden-xs{display:none!important} +} +@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important} +} +@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important} +} +@media (min-width:1200px){.hidden-lg{display:none!important} +} +.visible-print{display:none!important} +@media print{.visible-print{display:block!important} +table.visible-print{display:table!important} +tr.visible-print{display:table-row!important} +td.visible-print,th.visible-print{display:table-cell!important} +} +.visible-print-block{display:none!important} +@media print{.visible-print-block{display:block!important} +} +.visible-print-inline{display:none!important} +@media print{.visible-print-inline{display:inline!important} +} +.visible-print-inline-block{display:none!important} +@media print{.visible-print-inline-block{display:inline-block!important} +.hidden-print{display:none!important} +} + +.menu-container { + display: flex; + justify-content: center; + margin-bottom: 20px; + border-bottom: 1px solid #ddd; +} + +.menu-item { + padding: 10px 20px; + text-decoration: none; + color: #333; + font-weight: bold; + margin: 0 10px; + border-bottom: 2px solid transparent; + transition: color 0.3s, border-bottom 0.3s; +} + +.menu-item:hover { + color: #231F20; + border-bottom: 2px solid #231F20; +} + +.menu-item.active { + color: #231F20; + border-bottom: 2px solid #231F20; +} + +.alert {border-radius:8px !important;} \ No newline at end of file diff --git a/app/static/css/bootstrap-overrides/modals.css b/app/static/css/bootstrap-overrides/modals.css new file mode 100644 index 000000000..e00eb8294 --- /dev/null +++ b/app/static/css/bootstrap-overrides/modals.css @@ -0,0 +1,238 @@ +/* Bootstrap 3 overrides — modals */ +@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0} +to{background-position:0 0} +} +@keyframes progress-bar-stripes{from{background-position:40px 0} +to{background-position:0 0} +} +.progress{height:20px;background-color:#f5f5f5;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)} +.progress-bar{float:left;width:0;height:100%;line-height:20px;color:#fff;text-align:center;background-color:#5B5340;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease} +.progress-bar-striped,.progress-striped .progress-bar{background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:40px 40px} +.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite} +.progress-bar-success{background-color:#178c54} +.progress-striped .progress-bar-success{background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)} +.progress-bar-info{background-color:#42a2cb} +.progress-striped .progress-bar-info{background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)} +.progress-bar-warning{background-color:#e88035} +.progress-striped .progress-bar-warning{background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)} +.progress-bar-danger{background-color:#e18282} +.progress-striped .progress-bar-danger{background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)} +.media{margin-top:15px} +.media:first-child{margin-top:0} +.media,.media-body{zoom:1} +.media-body{width:10000px} +.media-object.img-thumbnail{max-width:none} +.media-right,.media>.pull-right{padding-left:10px} +.media-left,.media>.pull-left{padding-right:10px} +.media-body,.media-left,.media-right{display:table-cell;vertical-align:top} +.media-middle{vertical-align:middle} +.media-bottom{vertical-align:bottom} +.media-heading{margin-top:0;margin-bottom:5px} +.media-list{padding-left:0;list-style:none} +.list-group{padding-left:0;margin-bottom:20px} +.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd} +.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px} +.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px} +.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee} +.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit} +.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777} +.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#5B5340;border-color:#5B5340} +.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit} +.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#fefefd} +a.list-group-item,button.list-group-item{color:#555} +a.list-group-item .list-group-item-heading,button.list-group-item .list-group-item-heading{color:#333} +a.list-group-item:focus,a.list-group-item:hover,button.list-group-item:focus,button.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5} +button.list-group-item{width:100%} +.list-group-item-success{color:#3c763d;background-color:#dff0d8} +a.list-group-item-success,button.list-group-item-success{color:#3c763d} +a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit} +a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6} +a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d} +.list-group-item-info{color:#31708f;background-color:#d9edf7} +a.list-group-item-info,button.list-group-item-info{color:#31708f} +a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit} +a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3} +a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f} +.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3} +a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b} +a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit} +a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc} +a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b} +.list-group-item-danger{color:#a94442;background-color:#f2dede} +a.list-group-item-danger,button.list-group-item-danger{color:#a94442} +.panel-heading>.dropdown .dropdown-toggle,.panel-title,.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a,a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit} +a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc} +a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442} +.list-group-item-heading{margin-top:0;margin-bottom:5px} +.list-group-item-text{margin-bottom:0;line-height:1.3} +.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:8px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)} +.panel-default>.panel-heading,.panel-footer,.well{background-color:#f5f5f5} +.panel-title,.panel>.list-group,.panel>.panel-collapse>.list-group,.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive,.panel>.table-responsive>.table{margin-bottom:0} +.panel-heading{padding:10px 15px;border-top-left-radius:3px;border-top-right-radius:3px} +.panel-title{margin-top:0;font-size:16px} +.panel-footer{padding:10px 15px;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px} +.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0} +.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px} +.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px} +.panel>.panel-heading+.panel-collapse>.list-group .list-group-item:first-child{border-top-left-radius:0;border-top-right-radius:0} +.list-group+.panel-footer,.panel-heading+.list-group .list-group-item:first-child{border-top-width:0} +.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px} +.panel>.table-responsive:first-child>.table:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px} +.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px} +.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px} +.panel>.table-responsive:last-child>.table:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px} +.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px} +.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px} +.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd} +.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0} +.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0} +.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0} +.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0} +.panel>.table-responsive{border:0} +.panel-group{margin-bottom:20px} +.panel-group .panel{margin-bottom:0;border-radius:8px} +.panel-group .panel-heading{border-bottom:0} +.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd} +.panel-group .panel-footer{border-top:0} +.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd} +.panel-default{border-color:#ddd} +.panel-default>.panel-heading{color:#333;border-color:#ddd} +.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd} +.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333} +.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd} +.panel-primary{border-color:#5B5340} +.panel-primary>.panel-heading{color:#fff;background-color:#5B5340;border-color:#5B5340} +.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#5B5340} +.panel-primary>.panel-heading .badge{color:#5B5340;background-color:#fff} +.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#5B5340} +.panel-success{border-color:#d6e9c6} +.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6} +.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6} +.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d} +.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6} +.panel-info{border-color:#bce8f1} +.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1} +.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1} +.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f} +.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1} +.panel-warning{border-color:#faebcc} +.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc} +.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc} +.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b} +.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc} +.panel-danger{border-color:#ebccd1} +.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1} +.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1} +.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442} +.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1} +.embed-responsive{position:relative;display:block;height:0;padding:0} +.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0} +.embed-responsive-16by9{padding-bottom:56.25%} +.embed-responsive-4by3{padding-bottom:75%} +.well{padding:19px;margin-bottom:20px;border:1px solid #e3e3e3;border-radius:8px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)} +.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)} +.well-lg{padding:24px;border-radius:8px} +.well-sm{padding:9px;border-radius:8px} +.close{float:right;font-size:21px;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2} +.popover,.tooltip{text-decoration:none;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-style:normal;font-weight:400;line-height:1.42857143;line-break:auto;letter-spacing:normal;word-break:normal;word-spacing:normal;word-wrap:normal} +.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5} +button.close{padding:0;cursor:pointer;background:0 0;border:0;-webkit-appearance:none;appearance:none} +.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1050;display:none;-webkit-overflow-scrolling:touch;outline:0} +.modal.fade .modal-dialog{-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%);-webkit-transition:-webkit-transform .3s ease-out;-moz-transition:-moz-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out} +.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)} +.modal-open .modal{overflow-x:hidden;overflow-y:auto} +.modal-dialog{position:relative;width:auto;margin:10px} +.modal-content{position:relative;background-color:#fff;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:8px;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5);outline:0} +.modal-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;background-color:#000} +.carousel,.carousel-inner,.carousel-inner>.item,.modal-body{position:relative} +.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0} +.carousel-control,.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5} +.modal-header{padding:15px;border-bottom:1px solid #e5e5e5} +.tooltip.bottom .tooltip-arrow,.tooltip.bottom-left .tooltip-arrow,.tooltip.bottom-right .tooltip-arrow{top:0;border-width:0 5px 5px;border-bottom-color:#000} +.modal-header .close{margin-top:-2px} +.modal-title{margin:0;line-height:1.42857143} +.modal-body{padding:15px} +.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5} +.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px} +.modal-footer .btn-group .btn+.btn{margin-left:-1px} +.modal-footer .btn-block+.btn-block{margin-left:0} +.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll} +@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto} +.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)} +.modal-sm{width:300px} +} +.tooltip.top-left .tooltip-arrow,.tooltip.top-right .tooltip-arrow{bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000} +@media (min-width:992px){.modal-lg{width:900px} +} +.tooltip{position:absolute;z-index:1070;display:block;text-align:left;text-align:start;text-shadow:none;white-space:normal;filter:alpha(opacity=0);opacity:0} +.tooltip.in{filter:alpha(opacity=90);opacity:.9} +.tooltip.top{padding:5px 0;margin-top:-3px} +.tooltip.right{padding:0 5px;margin-left:3px} +.tooltip.bottom{padding:5px 0;margin-top:3px} +.tooltip.left{padding:0 5px;margin-left:-3px} +.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000} +.tooltip.top-left .tooltip-arrow{right:5px} +.tooltip.top-right .tooltip-arrow{left:5px} +.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000} +.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000} +.tooltip.bottom .tooltip-arrow{left:50%;margin-left:-5px} +.tooltip.bottom-left .tooltip-arrow{right:5px;margin-top:-5px} +.tooltip.bottom-right .tooltip-arrow{left:5px;margin-top:-5px} +.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;background-color:#000;border-radius:8px} +.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid} +.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;text-align:left;text-align:start;text-shadow:none;white-space:normal;font-size:14px;background-color:#fff;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:8px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2)} +.popover.top{margin-top:-10px} +.popover.right{margin-left:10px} +.popover.bottom{margin-top:10px} +.popover.left{margin-left:-10px} +.popover>.arrow{border-width:11px} +.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid} +.popover>.arrow:after{border-width:10px} +.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0} +.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0} +.popover.left>.arrow:after,.popover.right>.arrow:after{bottom:-10px;content:" "} +.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0} +.popover.right>.arrow:after{left:1px;border-right-color:#fff;border-left-width:0} +.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)} +.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff} +.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)} +.popover.left>.arrow:after{right:1px;border-right-width:0;border-left-color:#fff} +.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0} +.popover-content{padding:9px 14px} +.carousel-inner{width:100%} +.carousel-inner>.item{display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left} +.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1} +@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-moz-transition:-moz-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000px;-moz-perspective:1000px;perspective:1000px} +.carousel-inner>.item.active.right,.carousel-inner>.item.next{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);left:0} +.carousel-inner>.item.active.left,.carousel-inner>.item.prev{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);left:0} +.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);left:0} +} +.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block} +.carousel-inner>.active{left:0} +.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%} +.carousel-inner>.next{left:100%} +.carousel-inner>.prev{left:-100%} +.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0} +.carousel-inner>.active.left{left:-100%} +.carousel-inner>.active.right{left:100%} +.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);background-color:transparent} +.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x} +.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x} +.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;outline:0;filter:alpha(opacity=90);opacity:.9} +.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block;margin-top:-10px} +.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px} +.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px} +.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;font-family:serif;line-height:1} +.carousel-control .icon-prev:before{content:"\2039"} +.carousel-control .icon-next:before{content:"\203a"} +.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none} +.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000\9;background-color:transparent;border:1px solid #fff;border-radius:8px} +.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff} +.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)} +.carousel-caption .btn,.text-hide{text-shadow:none} +@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-10px;font-size:30px} +.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-10px} +.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-10px} +.carousel-caption{right:20%;left:20%;padding-bottom:30px} +.carousel-indicators{bottom:20px} +} diff --git a/app/static/css/bootstrap-overrides/nav.css b/app/static/css/bootstrap-overrides/nav.css new file mode 100644 index 000000000..784216b5b --- /dev/null +++ b/app/static/css/bootstrap-overrides/nav.css @@ -0,0 +1,249 @@ +/* Bootstrap 3 overrides — nav */ +.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle} +.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left} +.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2} +.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px} +.btn-toolbar{margin-left:-5px} +.btn-toolbar .btn,.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left} +.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px} +.btn-group>.btn:first-child{margin-left:0} +.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0} +.btn-group>.btn-group{float:left} +.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0} +.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0} +.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px} +.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px} +.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)} +.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none} +.btn .caret{margin-left:0} +.btn-lg .caret{border-width:5px 5px 0} +.dropup .btn-lg .caret{border-width:0 5px 5px} +.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%} +.btn-group-vertical>.btn-group>.btn{float:none} +.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0} +.btn-group-vertical>.btn:first-child:not(:last-child){border-radius:8px 4px 0 0} +.btn-group-vertical>.btn:last-child:not(:first-child){border-radius:0 0 4px 4px} +.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0} +.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0} +.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0} +.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate} +.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%} +.btn-group-justified>.btn-group .btn{width:100%} +.btn-group-justified>.btn-group .dropdown-menu{left:auto} +[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none} +.input-group,.input-group .form-control,.input-group-btn,.input-group-btn>.btn,.nav>li,.nav>li>a,.navbar{position:relative} +.input-group{display:table;border-collapse:separate} +.input-group[class*=col-]{float:none;padding-right:0;padding-left:0} +.input-group .form-control{z-index:2;float:left;width:100%;margin-bottom:0} +.input-group .form-control:focus{z-index:3} +.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:8px} +select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px} +select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto} +.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:8px} +select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px} +select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto} +.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell} +.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0} +.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle} +.input-group-addon{padding:6px 12px;font-size:14px;line-height:1;color:#555;background-color:#eee;border:1px solid #ccc;border-radius:8px} +.input-group-addon:last-child,.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0} +.badge,.label{text-align:center;font-weight:700;white-space:nowrap} +.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:8px} +.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:8px} +.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0} +.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0} +.input-group-addon:first-child{border-right:0} +.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0} +.input-group-btn{font-size:0;white-space:nowrap} +.input-group-btn>.btn+.btn{margin-left:-1px} +.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2} +.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px} +.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{z-index:2;margin-left:-1px} +.nav{padding-left:0;margin-bottom:0;list-style:none} +.nav>li{display:block} +.nav>li>a{display:block;padding:10px 15px} +.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee} +.nav>li.disabled>a{color:#777} +.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent} +.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#5B5340} +.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5} +.nav>li>a>img{max-width:none} +.nav-tabs{border-bottom:1px solid #ddd} +.nav-tabs>li{float:left;margin-bottom:-1px} +.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:8px 4px 0 0} +.nav-tabs>li>a:hover{border-color:#eee #eee #ddd} +.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff0;border:1px solid #ddd;border-bottom-color:transparent} +.nav-tabs.nav-justified{width:100%;border-bottom:0} +.nav-tabs.nav-justified>li{float:none} +.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center;margin-right:0;border-radius:8px} +.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto} +.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd} +@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%} +.nav-tabs.nav-justified>li>a{margin-bottom:0;border-bottom:1px solid #ddd;border-radius:8px 4px 0 0} +.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff0} +} +.nav-pills>li{float:left} +.nav-justified>li,.nav-stacked>li{float:none} +.nav-pills>li>a{border-radius:8px} +.nav-pills>li+li{margin-left:2px} +.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#5B5340} +.nav-stacked>li+li{margin-top:2px;margin-left:0} +.nav-justified{width:100%} +.nav-justified>li>a{margin-bottom:5px;text-align:center} +.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto} +.nav-tabs-justified{border-bottom:0} +.nav-tabs-justified>li>a{margin-right:0;border-radius:8px} +.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd} +@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%} +.nav-justified>li>a{margin-bottom:0} +.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:8px 4px 0 0} +.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff0} +} +.tab-content>.tab-pane{display:none} +.tab-content>.active{display:block} +.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0} +.navbar,.navbar-toggle{border:1px solid transparent} +.navbar{min-height:50px;margin-bottom:20px} +.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;border-top:1px solid transparent;box-shadow:inset 0 1px 0 rgba(255,255,255,.1);-webkit-overflow-scrolling:touch} +.navbar-collapse.in{overflow-y:auto} +@media (min-width:768px){.navbar{border-radius:8px} +.navbar-header{float:left} +.navbar-collapse{width:auto;border-top:0;box-shadow:none} +.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important} +.navbar-collapse.in{overflow-y:visible} +.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0} +} +.carousel-inner,.embed-responsive,.media,.media-body,.modal,.modal-open,.progress{overflow:hidden} +.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030} +.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px} +@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px} +} +.navbar-fixed-top{top:0;border-width:0 0 1px} +.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0} +.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px} +.navbar-static-top{z-index:1000;border-width:0 0 1px} +.navbar-brand{float:left;height:50px;padding:15px;font-size:18px;line-height:20px} +.navbar-brand:focus,.navbar-brand:hover{text-decoration:none} +.navbar-brand>img{display:block} +@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0} +.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0} +.navbar-static-top{border-radius:0} +.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px} +} +.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-right:15px;margin-top:8px;margin-bottom:8px;background-color:transparent;background-image:none;border-radius:8px} +.navbar-toggle:focus{outline:0} +.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px} +.navbar-toggle .icon-bar+.icon-bar{margin-top:4px} +.navbar-nav{margin:7.5px -15px} +.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px} +@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;box-shadow:none} +.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px} +.navbar-nav .open .dropdown-menu>li>a{line-height:20px} +.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none} +} +.progress-bar-striped,.progress-striped .progress-bar,.progress-striped .progress-bar-danger,.progress-striped .progress-bar-info,.progress-striped .progress-bar-success,.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)} +.navbar-form,.panel-heading{border-bottom:1px solid transparent} +@media (min-width:768px){.navbar-toggle{display:none} +.navbar-nav{float:left;margin:0} +.navbar-nav>li{float:left} +.navbar-nav>li>a{padding-top:15px;padding-bottom:15px} +} +.navbar-form{padding:10px 15px;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);margin:8px -15px} +@media (min-width:768px){.navbar-form .form-control,.navbar-form .form-control-static,.navbar-form .form-group{display:inline-block} +.navbar-form .checkbox,.navbar-form .control-label,.navbar-form .form-group,.navbar-form .radio{margin-bottom:0;vertical-align:middle} +.navbar-form .form-control{width:auto;vertical-align:middle} +.navbar-form .input-group{display:inline-table;vertical-align:middle} +.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto} +.navbar-form .input-group>.form-control{width:100%} +.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0} +.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0} +.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0} +.navbar-form .has-feedback .form-control-feedback{top:0} +} +@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px} +.navbar-form .form-group:last-child{margin-bottom:0} +} +@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none} +} +.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0} +.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-radius:8px 4px 0 0} +.breadcrumb,.pagination,.progress{border-radius:8px} +.navbar-btn{margin-top:8px;margin-bottom:8px} +.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px} +.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px} +.navbar-text{margin-top:15px;margin-bottom:15px} +@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px} +.navbar-left{float:left!important} +.navbar-right{float:right!important;margin-right:-15px} +.navbar-right~.navbar-right{margin-right:0} +} +.navbar-default{background-color:#fff;border-color:#eee} +.navbar-default .navbar-brand{color:#777} +.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent} +.navbar-default .navbar-nav>li>a,.navbar-default .navbar-text{color:#777} +.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent} +.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#eee} +.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent} +.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#eee} +@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777} +.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent} +.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#eee} +.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent} +} +.navbar-default .navbar-toggle{border-color:#ddd} +.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd} +.navbar-default .navbar-toggle .icon-bar{background-color:#888} +.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#eee} +.navbar-default .navbar-link{color:#777} +.navbar-default .navbar-link:hover{color:#333} +.navbar-default .btn-link{color:#777} +.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333} +.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc} +.navbar-inverse{background-color:#222;border-color:#080808} +.navbar-inverse .navbar-brand{color:#9d9d9d} +.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent} +.navbar-inverse .navbar-nav>li>a,.navbar-inverse .navbar-text{color:#9d9d9d} +.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent} +.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808} +.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent} +.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808} +@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808} +.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808} +.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d} +.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent} +.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808} +.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent} +} +.navbar-inverse .navbar-toggle{border-color:#333} +.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333} +.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff} +.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010} +.navbar-inverse .navbar-link{color:#9d9d9d} +.navbar-inverse .navbar-link:hover{color:#fff} +.navbar-inverse .btn-link{color:#9d9d9d} +.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff} +.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444} +.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5} +.breadcrumb>li{display:inline-block} +.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"} +.breadcrumb>.active{color:#777} +.pagination{display:inline-block;padding-left:0;margin:20px 0} +.pager li,.pagination>li{display:inline} +.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#5B5340;text-decoration:none;background-color:#fff;border:1px solid #ddd} +.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{z-index:2;color:#231F20;background-color:#eee;border-color:#ddd} +.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px} +.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px} +.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:3;color:#fff;cursor:default;background-color:#5B5340;border-color:#5B5340} +.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd} +.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px;line-height:1.3333333} +.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px} +.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px} +.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px;line-height:1.5} +.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px} +.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px} +.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none} +.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px} +.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee} +.pager .next>a,.pager .next>span{float:right} +.pager .previous>a,.pager .previous>span{float:left} +.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff} diff --git a/app/static/css/bootstrap-overrides/print.css b/app/static/css/bootstrap-overrides/print.css new file mode 100644 index 000000000..4668b948d --- /dev/null +++ b/app/static/css/bootstrap-overrides/print.css @@ -0,0 +1,19 @@ +/* Bootstrap 3 overrides — print */ +@media print{blockquote,img,pre,tr{page-break-inside:avoid} +*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;box-shadow:none!important} +a,a:visited{text-decoration:underline} +a[href]:after{content:" (" attr(href) ")"} +abbr[title]:after{content:" (" attr(title) ")"} +a[href^="javascript:"]:after,a[href^="#"]:after{content:""} +blockquote,pre{border:1px solid #999} +thead{display:table-header-group} +img{max-width:100%!important} +h2,h3,p{orphans:3;widows:3} +h2,h3{page-break-after:avoid} +.navbar{display:none} +.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important} +.label{border:1px solid #000} +.table{border-collapse:collapse!important} +.table td,.table th{background-color:#fff!important} +.table-bordered td,.table-bordered th{border:1px solid #ddd!important} +} diff --git a/app/static/css/bootstrap-overrides/scaffolding.css b/app/static/css/bootstrap-overrides/scaffolding.css new file mode 100644 index 000000000..f17cd2d88 --- /dev/null +++ b/app/static/css/bootstrap-overrides/scaffolding.css @@ -0,0 +1,16 @@ +/* Bootstrap 3 overrides — scaffolding */ +*,:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box} +body{margin:0;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;color:#333} +button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit} +a{color:#5B5340;text-decoration:none} +a:focus,a:hover{color:#231F20;text-decoration:underline} +a:focus{outline:-webkit-focus-ring-color auto 5px;outline-offset:-2px} +figure{margin:0} +.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto} +.img-rounded{border-radius:8px} +.img-thumbnail{padding:4px;border:1px solid #ddd;border-radius:8px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out;max-width:100%;height:auto} +.img-circle{border-radius:50%} +hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee} +.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0} +.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto} +[role=button]{cursor:pointer} diff --git a/app/static/css/bootstrap-overrides/tables.css b/app/static/css/bootstrap-overrides/tables.css new file mode 100644 index 000000000..8319fd01c --- /dev/null +++ b/app/static/css/bootstrap-overrides/tables.css @@ -0,0 +1,32 @@ +/* Bootstrap 3 overrides — tables */ +caption{padding-top:8px;padding-bottom:8px;color:#777} +.table{width:100%;max-width:100%;margin-bottom:20px} +.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd} +.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd} +.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0} +.table>tbody+tbody{border-top:2px solid #ddd} +.table .table{background-color:#fff0} +.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px} +.table-bordered,.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd} +.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px} +.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9} +.table-hover>tbody>tr:hover,.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5} +.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8} +.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8} +.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6} +.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7} +.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3} +.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3} +.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc} +.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede} +.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc} +.table-responsive{min-height:.01%;overflow-x:auto} +.checkbox label,.radio label,.well{min-height:20px} +@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd} +.table-responsive>.table{margin-bottom:0} +.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap} +.table-responsive>.table-bordered{border:0} +.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0} +.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0} +.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0} +} diff --git a/app/static/css/bootstrap-overrides/typography.css b/app/static/css/bootstrap-overrides/typography.css new file mode 100644 index 000000000..ddc1386df --- /dev/null +++ b/app/static/css/bootstrap-overrides/typography.css @@ -0,0 +1,83 @@ +/* Bootstrap 3 overrides — typography */ +.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit} +.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777} +.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px} +.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%} +.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px} +.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%} +.h1,h1{font-size:36px} +.h2,h2{font-size:30px} +.h3,h3{font-size:24px} +.h4,h4{font-size:18px} +.h5,h5{font-size:14px} +.h6,h6{font-size:12px} +p{margin:0 0 10px} +.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4} +dt,kbd kbd,label{font-weight:700} +@media (min-width:768px){.lead{font-size:21px} +} +.small,small{font-size:85%} +.mark,mark{padding:.2em;background-color:#fcf8e3} +.bg-primary,.btn-default{color:#fff; background-color:#5B5340} +.list-inline,.list-unstyled{list-style:none;padding-left:0} +.text-left{text-align:left} +.text-right{text-align:right} +.text-center{text-align:center} +.text-justify{text-align:justify} +.text-nowrap{white-space:nowrap} +.text-lowercase{text-transform:lowercase} +.text-uppercase{text-transform:uppercase} +.text-capitalize{text-transform:capitalize} +.text-muted{color:#777} +.text-primary{color:#5B5340} +a.text-primary:focus,a.text-primary:hover{color:#231F20} +.text-success{color:#3c763d} +a.text-success:focus,a.text-success:hover{color:#2b542c} +.text-info{color:#31708f} +a.text-info:focus,a.text-info:hover{color:#245269} +.text-warning{color:#8a6d3b} +a.text-warning:focus,a.text-warning:hover{color:#66512c} +.text-danger{color:#a94442} +a.text-danger:focus,a.text-danger:hover{color:#843534} +.bg-primary{color:#fff} +a.bg-primary:focus,a.bg-primary:hover{background-color:#231F20} +.bg-success{background-color:#dff0d8} +a.bg-success:focus,a.bg-success:hover{background-color:#c1e2b3} +.bg-info{background-color:#d9edf7} +a.bg-info:focus,a.bg-info:hover{background-color:#afd9ee} +.bg-warning{background-color:#fcf8e3} +a.bg-warning:focus,a.bg-warning:hover{background-color:#f7ecb5} +.bg-danger{background-color:#f2dede} +a.bg-danger:focus,a.bg-danger:hover{background-color:#e4b9b9} +pre code,table{background-color:transparent} +.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee} +dl,ol,ul{margin-top:0} +ol,ul{margin-bottom:10px} +ol ol,ol ul,ul ol,ul ul{margin-bottom:0} +.list-inline{margin-left:-5px} +.list-inline>li{padding-right:5px;padding-left:5px} +dl{margin-bottom:20px} +dd,dt{line-height:1.42857143} +dd{margin-left:0} +@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;clear:left;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.dl-horizontal dd{margin-left:180px} +} +abbr[data-original-title],abbr[title]{cursor:help} +.checkbox.disabled label,.form-control[disabled],.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .form-control,fieldset[disabled] .radio label,fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed} +.initialism{font-size:90%;text-transform:uppercase} +blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee} +blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0} +blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777} +blockquote .small:before,blockquote footer:before,blockquote small:before{content:"\2014 \00A0"} +.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,.dropup .caret,.navbar-fixed-bottom .dropdown .caret,.popover>.arrow:after,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:""} +.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0} +.dropdown-menu,button.list-group-item,caption,th{text-align:left} +code,kbd{padding:2px 4px;font-size:90%} +.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:"\00A0 \2014"} +address{margin-bottom:20px;font-style:normal;line-height:1.42857143} +code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace} +code{color:#c7254e;background-color:#f9f2f4;border-radius:8px} +kbd{color:#fff;background-color:#333;border-radius:8px;box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)} +kbd kbd{padding:0;font-size:100%;box-shadow:none} +pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:8px} +pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;border-radius:0} diff --git a/app/static/css/bootstrap-overrides/variables.css b/app/static/css/bootstrap-overrides/variables.css new file mode 100644 index 000000000..4d7175ebe --- /dev/null +++ b/app/static/css/bootstrap-overrides/variables.css @@ -0,0 +1,38 @@ +/* Bootstrap 3 overrides — variables */ +/*! + * Bootstrap v3.4.0 (https://getbootstrap.com/) + * Copyright 2011-2018 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ +.label,audio,canvas,progress,sub,sup,video{vertical-align:baseline} +.popover,.tooltip,button,select{text-transform:none} +html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;font-size:10px;-webkit-tap-highlight-color:transparent} +article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block} +audio,canvas,progress,video{display:inline-block} +audio:not([controls]){display:none;height:0} +[hidden],template{display:none} +a{background-color:transparent} +a:active,a:hover{outline:0} +abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted} +b,optgroup,strong{font-weight:700} +dfn{font-style:italic} +h1{margin:.67em 0} +mark{background:#ff0;color:#000} +sub,sup{font-size:75%;line-height:0;position:relative} +sup{top:-.5em} +sub{bottom:-.25em} +img{border:0;vertical-align:middle} +svg:not(:root){overflow:hidden} +hr{box-sizing:content-box;height:0} +pre,textarea{overflow:auto} +code,kbd,pre,samp{font-size:1em} +button,input,optgroup,select,textarea{color:inherit;font:inherit;margin:0} +button{overflow:visible} +button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer} +button[disabled],html input[disabled]{cursor:default} +button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0} +input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0} +input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto} +input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none} +table{border-collapse:collapse;border-spacing:0} +td,th{padding:0} diff --git a/app/static/css/main.css b/app/static/css/main.css index 69e30d777..c1f84eacf 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -386,21 +386,7 @@ input:-webkit-autofill background-color: transparent !important; } -.c3-axis-y text { - fill: #000000; -} -.c3-axis-x text { - fill: #000000; -} -.c3-legend-item { - fill: #000000; -} -.c3 .c3-axis line, .c3 .c3-axis path { - stroke: #000000; -} -.c3-tooltip tr { - color: #222222; -} +/* c3 chart styles moved to stats/static/css/stats.css */ .row { margin-left: 0px; @@ -411,24 +397,7 @@ input:-webkit-autofill color: black; } -.container-iframe { - position: relative; - overflow: hidden; - width: 100%; - padding-top: 56.25%; /* 16:9 Aspect Ratio (divide 9 by 16 = 0.5625) */ -} - -.responsive-iframe { - display: block; - border-style:none; - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - width: 100%; - height: 100%; -} +/* .container-iframe and .responsive-iframe moved to organizers/static/css/organizers.css */ /*.daterangepicker.show-calendar {*/ /* background-color: #17141e;*/ diff --git a/app/templates/base.html b/app/templates/base.html index de0228562..221e83b3a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -50,7 +50,20 @@ - + + + + + + + + + + + + + + diff --git a/applications/views/__init__.py b/applications/views/__init__.py new file mode 100644 index 000000000..130ee723d --- /dev/null +++ b/applications/views/__init__.py @@ -0,0 +1,23 @@ +from applications.views.hacker import ( + VIEW_APPLICATION_TYPE, + VIEW_APPLICATION_FORM_TYPE, + check_application_exists, + get_deadline, + user_is_in_blacklist, + ConfirmApplication, + CancelApplication, + HackerDashboard, + HackerApplication, +) +from applications.views.sponsor import SponsorApplicationView, SponsorDashboard +from applications.views.mentor import ConvertHackerToMentor +from applications.views.draft import save_draft + +__all__ = [ + 'VIEW_APPLICATION_TYPE', 'VIEW_APPLICATION_FORM_TYPE', + 'check_application_exists', 'get_deadline', 'user_is_in_blacklist', + 'ConfirmApplication', 'CancelApplication', 'HackerDashboard', 'HackerApplication', + 'SponsorApplicationView', 'SponsorDashboard', + 'ConvertHackerToMentor', + 'save_draft', +] diff --git a/applications/views/draft.py b/applications/views/draft.py new file mode 100644 index 000000000..9c7680eda --- /dev/null +++ b/applications/views/draft.py @@ -0,0 +1,26 @@ +from django.http import JsonResponse + +from applications import models, forms +from applications.views.hacker import VIEW_APPLICATION_TYPE, VIEW_APPLICATION_FORM_TYPE +from user.mixins import is_hacker + + +@is_hacker +def save_draft(request): + Application = VIEW_APPLICATION_TYPE.get(request.user.type, models.HackerApplication) + ApplicationForm = VIEW_APPLICATION_FORM_TYPE.get( + request.user.type, forms.HackerApplicationForm + ) + d = models.DraftApplication() + d.user = request.user + form_keys = set(dict(ApplicationForm().fields).keys()) + valid_keys = set([field.name for field in Application()._meta.get_fields()]) + d.save_dict( + dict( + (k, v) + for k, v in request.POST.items() + if k in valid_keys.intersection(form_keys) and v + ) + ) + d.save() + return JsonResponse({"saved": True}) diff --git a/applications/views.py b/applications/views/hacker.py similarity index 70% rename from applications/views.py rename to applications/views/hacker.py index b6b0c8786..2e27af273 100644 --- a/applications/views.py +++ b/applications/views/hacker.py @@ -1,4 +1,3 @@ -# Create your views here. from __future__ import print_function import logging @@ -8,32 +7,24 @@ from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import ValidationError -from django.db import IntegrityError -from django.http import Http404, HttpResponseRedirect, JsonResponse +from django.http import Http404, HttpResponseRedirect from django.shortcuts import render, get_object_or_404, redirect from django.utils import timezone -from django.utils.encoding import force_text -from django.utils.http import urlsafe_base64_decode from django.views import View -from django.views.generic import TemplateView from app import slack from app.hackathon_variables import MAX_REIMBURSEMENTS_UNTIL_WAITLIST from app.slack import SlackInvitationException -from app.utils import reverse, hacker_tabs +from app.utils import reverse, hacker_tabs, generateGTicketUrl, isset from app.views import TabsView from applications import models, emails, forms -from organizers.tables import SponsorFilter, SponsorListTableWithNoAction -from organizers.views import _OtherApplicationsListView from reimbursement.models import RE_WAITLISTED, Reimbursement -from user.mixins import IsHackerMixin, is_hacker, IsSponsorMixin, DashboardMixin +from user.mixins import IsHackerMixin, DashboardMixin from user import models as userModels from reimbursement import emails as reimb_emails from django.conf import settings -from app.utils import generateGTicketUrl, isset - VIEW_APPLICATION_TYPE = { userModels.USR_HACKER: models.HackerApplication, userModels.USR_VOLUNTEER: models.VolunteerApplication, @@ -360,136 +351,6 @@ def post(self, request, *args, **kwargs): return render(request, self.template_name, c) -class SponsorApplicationView(TemplateView): - template_name = "dashboard.html" - - def get_context_data(self, **kwargs): - context = super(SponsorApplicationView, self).get_context_data(**kwargs) - form = forms.SponsorForm() - context.update({"form": form, "is_sponsor": True}) - try: - uid = force_text(urlsafe_base64_decode(self.kwargs.get("uid", None))) - user = userModels.User.objects.get(pk=uid) - context.update({"user": user}) - context.update({"company_name": user.name}) - except (TypeError, ValueError, OverflowError, userModels.User.DoesNotExist): - pass - - return context - - def get(self, request, *args, **kwargs): - try: - uid = force_text(urlsafe_base64_decode(self.kwargs.get("uid", None))) - user = userModels.User.objects.get(pk=uid) - real_token = userModels.Token.objects.get(pk=user).uuid_str() - token = self.kwargs.get("token", None) - except ( - TypeError, - ValueError, - OverflowError, - userModels.User.DoesNotExist, - userModels.Token.DoesNotExist, - ): - raise Http404("Invalid url") - if token != real_token: - raise Http404("Invalid url") - if not user.has_applications_left()[0]: - raise Http404("You have no applications left") - return super(SponsorApplicationView, self).get(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - form = forms.SponsorForm(request.POST, request.FILES) - try: - uid = force_text(urlsafe_base64_decode(self.kwargs.get("uid", None))) - user = userModels.User.objects.get(pk=uid) - except (TypeError, ValueError, OverflowError, userModels.User.DoesNotExist): - return Http404("How did you get here?") - has_applications_left, applied = user.has_applications_left() - if not has_applications_left: - form.add_error(None, "You have no applications left") - elif form.is_valid(): - name = form.cleaned_data["name"] - app = models.SponsorApplication.objects.filter(user=user, name=name).first() - if app: - form.add_error("name", "This name is already taken. Have you applied?") - else: - user.pk = None - user.max_applications = 0 - application = form.save(commit=False) - user.password = "" - error = True - while error: - user.email = ("+%s@" % applied).join(user.email.split("@")) - try: - user.save() - error = False - except IntegrityError: - applied += 1 - application.user = user - application.save() - messages.success(request, "We have now received your application. ") - return render(request, "sponsor_submitted.html") - c = self.get_context_data() - c.update({"form": form}) - return render(request, self.template_name, c) - - -class ConvertHackerToMentor(TemplateView): - template_name = "convert_mentor.html" - - def get(self, request, *args, **kwargs): - if request.user.application.is_invalid(): - return super(ConvertHackerToMentor, self).get(request, *args, **kwargs) - return Http404 - - def post(self, request, *args, **kwargs): - if request.user.application.is_invalid(): - request.user.set_mentor() - request.user.save() - messages.success(request, "Thanks for coming as mentor!") - else: - messages.error(request, "You have no permissions to do this") - return HttpResponseRedirect(reverse("dashboard")) - - -class SponsorDashboard(IsSponsorMixin, _OtherApplicationsListView): - table_class = SponsorListTableWithNoAction - filterset_class = SponsorFilter - - def get_current_tabs(self): - return None - - def get_queryset(self): - return models.SponsorApplication.objects.filter(user=self.request.user) - - def get_context_data(self, **kwargs): - context = super(SponsorDashboard, self).get_context_data(**kwargs) - context["otherApplication"] = True - context["emailCopy"] = False - return context - - -@is_hacker -def save_draft(request): - Application = VIEW_APPLICATION_TYPE.get(request.user.type, models.HackerApplication) - ApplicationForm = VIEW_APPLICATION_FORM_TYPE.get( - request.user.type, forms.HackerApplicationForm - ) - d = models.DraftApplication() - d.user = request.user - form_keys = set(dict(ApplicationForm().fields).keys()) - valid_keys = set([field.name for field in Application()._meta.get_fields()]) - d.save_dict( - dict( - (k, v) - for k, v in request.POST.items() - if k in valid_keys.intersection(form_keys) and v - ) - ) - d.save() - return JsonResponse({"saved": True}) - - def user_is_in_blacklist(user): result = True blacklist_user = models.BlacklistUser.objects.filter(email=user.email).first() diff --git a/applications/views/mentor.py b/applications/views/mentor.py new file mode 100644 index 000000000..7254429b1 --- /dev/null +++ b/applications/views/mentor.py @@ -0,0 +1,23 @@ +from django.contrib import messages +from django.http import Http404, HttpResponseRedirect +from django.views.generic import TemplateView + +from app.utils import reverse + + +class ConvertHackerToMentor(TemplateView): + template_name = "convert_mentor.html" + + def get(self, request, *args, **kwargs): + if request.user.application.is_invalid(): + return super(ConvertHackerToMentor, self).get(request, *args, **kwargs) + return Http404 + + def post(self, request, *args, **kwargs): + if request.user.application.is_invalid(): + request.user.set_mentor() + request.user.save() + messages.success(request, "Thanks for coming as mentor!") + else: + messages.error(request, "You have no permissions to do this") + return HttpResponseRedirect(reverse("dashboard")) diff --git a/applications/views/sponsor.py b/applications/views/sponsor.py new file mode 100644 index 000000000..6cbabc559 --- /dev/null +++ b/applications/views/sponsor.py @@ -0,0 +1,104 @@ +from django.contrib import messages +from django.db import IntegrityError +from django.http import Http404 +from django.shortcuts import render +from django.utils.encoding import force_text +from django.utils.http import urlsafe_base64_decode +from django.views.generic import TemplateView + +from applications import models, forms +from organizers.tables import SponsorFilter, SponsorListTableWithNoAction +from organizers.views import _OtherApplicationsListView +from user.mixins import IsSponsorMixin +from user import models as userModels + + +class SponsorApplicationView(TemplateView): + template_name = "dashboard.html" + + def get_context_data(self, **kwargs): + context = super(SponsorApplicationView, self).get_context_data(**kwargs) + form = forms.SponsorForm() + context.update({"form": form, "is_sponsor": True}) + try: + uid = force_text(urlsafe_base64_decode(self.kwargs.get("uid", None))) + user = userModels.User.objects.get(pk=uid) + context.update({"user": user}) + context.update({"company_name": user.name}) + except (TypeError, ValueError, OverflowError, userModels.User.DoesNotExist): + pass + + return context + + def get(self, request, *args, **kwargs): + try: + uid = force_text(urlsafe_base64_decode(self.kwargs.get("uid", None))) + user = userModels.User.objects.get(pk=uid) + real_token = userModels.Token.objects.get(pk=user).uuid_str() + token = self.kwargs.get("token", None) + except ( + TypeError, + ValueError, + OverflowError, + userModels.User.DoesNotExist, + userModels.Token.DoesNotExist, + ): + raise Http404("Invalid url") + if token != real_token: + raise Http404("Invalid url") + if not user.has_applications_left()[0]: + raise Http404("You have no applications left") + return super(SponsorApplicationView, self).get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + form = forms.SponsorForm(request.POST, request.FILES) + try: + uid = force_text(urlsafe_base64_decode(self.kwargs.get("uid", None))) + user = userModels.User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, userModels.User.DoesNotExist): + return Http404("How did you get here?") + has_applications_left, applied = user.has_applications_left() + if not has_applications_left: + form.add_error(None, "You have no applications left") + elif form.is_valid(): + name = form.cleaned_data["name"] + app = models.SponsorApplication.objects.filter(user=user, name=name).first() + if app: + form.add_error("name", "This name is already taken. Have you applied?") + else: + user.pk = None + user.max_applications = 0 + application = form.save(commit=False) + user.password = "" + error = True + while error: + user.email = ("+%s@" % applied).join(user.email.split("@")) + try: + user.save() + error = False + except IntegrityError: + applied += 1 + application.user = user + application.save() + messages.success(request, "We have now received your application. ") + return render(request, "sponsor_submitted.html") + c = self.get_context_data() + c.update({"form": form}) + return render(request, self.template_name, c) + + +class SponsorDashboard(IsSponsorMixin, _OtherApplicationsListView): + table_class = SponsorListTableWithNoAction + filterset_class = SponsorFilter + + def get_current_tabs(self): + return None + + def get_queryset(self): + return models.SponsorApplication.objects.filter(user=self.request.user) + + def get_context_data(self, **kwargs): + context = super(SponsorDashboard, self).get_context_data(**kwargs) + context["otherApplication"] = True + context["emailCopy"] = False + return context diff --git a/baggage/views/__init__.py b/baggage/views/__init__.py new file mode 100644 index 000000000..7d6e8aea9 --- /dev/null +++ b/baggage/views/__init__.py @@ -0,0 +1,22 @@ +from baggage.views.volunteer import ( + baggage_checkIn, + baggage_checkOut, + organizer_tabs, + BaggageList, + BaggageHacker, + BaggageUsers, + BaggageAdd, + BaggageDetail, + BaggageMap, + BaggageHistory, + BaggageAPI, +) +from baggage.views.hacker import hacker_tabs, BaggageCurrentHacker + +__all__ = [ + 'baggage_checkIn', 'baggage_checkOut', 'organizer_tabs', + 'BaggageList', 'BaggageHacker', 'BaggageUsers', + 'BaggageAdd', 'BaggageDetail', 'BaggageMap', 'BaggageHistory', + 'BaggageAPI', + 'hacker_tabs', 'BaggageCurrentHacker', +] diff --git a/baggage/views/hacker.py b/baggage/views/hacker.py new file mode 100644 index 000000000..d95ab964a --- /dev/null +++ b/baggage/views/hacker.py @@ -0,0 +1,27 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin + +from app.mixins import TabsViewMixin +from baggage.models import Bag, BAG_ADDED +from baggage.tables import BaggageCurrentHackerTable, BaggageListFilter + + +def hacker_tabs(user): + t = [('Baggage', reverse('baggage_currenthacker'), False)] + return t + + +class BaggageCurrentHacker(LoginRequiredMixin, TabsViewMixin, SingleTableMixin, FilterView): + template_name = 'baggage_currenthacker.html' + table_class = BaggageCurrentHackerTable + filterset_class = BaggageListFilter + table_pagination = {'per_page': 100} + + def get_current_tabs(self): + return hacker_tabs(self.request.user) + + def get_queryset(self): + user = self.request.user + return Bag.objects.filter(status=BAG_ADDED, owner=user) diff --git a/baggage/views.py b/baggage/views/volunteer.py similarity index 93% rename from baggage/views.py rename to baggage/views/volunteer.py index da0dfd14e..eb07bc9c2 100644 --- a/baggage/views.py +++ b/baggage/views/volunteer.py @@ -1,29 +1,26 @@ import json +import base64 +import time +from django.conf import settings +from django.contrib import messages +from django.core.files.base import ContentFile +from django.http import HttpResponse, HttpResponseRedirect, Http404, JsonResponse +from django.shortcuts import redirect, get_object_or_404 from django.urls import reverse +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin +from rest_framework.views import APIView from app.mixins import TabsViewMixin from app.services.messages import MessageManager -from baggage.tables import BaggageListTable, BaggageListFilter, BaggageUsersTable -from baggage.tables import BaggageUsersFilter, BaggageCurrentHackerTable -from baggage.models import Bag, BAG_ADDED, BAG_REMOVED, Room -from user.models import User -from checkin.models import CheckIn -from django_tables2 import SingleTableMixin -from django_filters.views import FilterView from app.views import TabsView -from rest_framework.views import APIView -from django.contrib import messages -from django.http import HttpResponseRedirect, Http404 -from django.shortcuts import redirect, get_object_or_404 +from baggage.models import Bag, BAG_ADDED, BAG_REMOVED, Room +from baggage.tables import BaggageListTable, BaggageListFilter, BaggageUsersTable, BaggageUsersFilter from baggage import utils -import base64 -from django.core.files.base import ContentFile -import time +from checkin.models import CheckIn from user.mixins import IsVolunteerMixin -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, JsonResponse -from django.conf import settings +from user.models import User def baggage_checkIn(request, bag, bagrow, bagcol, bagroom, posmanual, bagspe): @@ -74,11 +71,6 @@ def organizer_tabs(user): return t -def hacker_tabs(user): - t = [('Baggage', reverse('baggage_currenthacker'), False)] - return t - - class BaggageList(IsVolunteerMixin, TabsViewMixin, SingleTableMixin, FilterView): template_name = 'baggage_list.html' table_class = BaggageListTable @@ -257,20 +249,6 @@ def get_context_data(self, **kwargs): return context -class BaggageCurrentHacker(LoginRequiredMixin, TabsViewMixin, SingleTableMixin, FilterView): - template_name = 'baggage_currenthacker.html' - table_class = BaggageCurrentHackerTable - filterset_class = BaggageListFilter - table_pagination = {'per_page': 100} - - def get_current_tabs(self): - return hacker_tabs(self.request.user) - - def get_queryset(self): - user = self.request.user - return Bag.objects.filter(status=BAG_ADDED, owner=user) - - class BaggageAPI(APIView): def get(self, request, format=None): diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 000000000..86b8e5d11 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,87 @@ +# Contributing — Common Changes + +This document walks through the most common yearly changes with exact file paths. + +--- + +## Example 1: Adding or Removing a Field from the Hacker Application + +The hacker application form is split across three layers: model, form, and template. + +### 1. Update the model + +File: `applications/models/hacker.py` + +Add or remove the field from the `HackerApplication` model class. Use standard Django field types. Example: + +```python +# Adding a new field +dietary_restrictions = models.CharField( + max_length=200, + blank=True, + help_text="Any dietary restrictions we should know about?" +) +``` + +### 2. Create and run a migration + +```bash +python manage.py makemigrations +python manage.py migrate +``` + +### 3. Update the form + +File: `applications/forms/hacker.py` + +Add the field name to the `fields` list in `HackerApplicationForm.Meta`, and optionally customize its widget or label in the `widgets` or `labels` dicts. + +### 4. Update the template + +File: `applications/templates/include/application_form.html` (or the relevant template — look for the `{% for field in form %}` loop or the specific field rendering) + +If you need custom layout for the new field, add it explicitly. Otherwise it will be rendered automatically by the form loop. + +### 5. Verify + +Start the dev server, go to the hacker application form, and confirm the new field appears. + +--- + +## Example 2: Adding or Removing a Field from the Volunteer or Mentor Application + +Same pattern as the hacker application, different files: + +| Layer | Volunteer | Mentor | +|---|---|---| +| Model | `applications/models/volunteer.py` | `applications/models/mentor.py` | +| Form | `applications/forms/volunteer.py` | `applications/forms/mentor.py` | +| Template | `applications/templates/` (look for volunteer/mentor form template) | same | + +Run `python manage.py makemigrations && python manage.py migrate` after changing the model. + +--- + +## Example 3: Changing the Travel Reimbursement Cap + +File: `app/hackathon_variables.py` + +Find and update the reimbursement limit variables. No migration or code change needed — these are config values read at runtime. + +--- + +## Where to Look for Other Things + +| What you want to change | Where to look | +|---|---| +| Application status flow (pending → invited → confirmed) | `applications/models/base.py` | +| Who can review applications and vote | `organizers/views/review.py` | +| Batch invite algorithm | `organizers/views/batch_ops.py` | +| Email content | See `docs/email-templates.md` | +| Check-in logic | `checkin/views.py` | +| User roles and permissions | `user/models.py`, `user/mixins.py` | +| Hackathon name, dates, deadlines | `app/hackathon_variables.py` | +| Global styles | `app/static/css/main.css` | +| Bootstrap component styles | `app/static/css/bootstrap-overrides/` | + +> **Note:** `organizers/views/review.py` and `organizers/views/batch_ops.py` are created as part of this refactor. Until the refactor is complete, this logic lives in `organizers/views.py`. diff --git a/docs/email-templates.md b/docs/email-templates.md new file mode 100644 index 000000000..151c11bdd --- /dev/null +++ b/docs/email-templates.md @@ -0,0 +1,88 @@ +# Email Templates + +myhackupc sends emails using Django's template system. Each email consists of two template files: +- `*_subject.txt` — the subject line (one line of text, no HTML) +- `*_message.html` — the email body (HTML) + +--- + +## All Emails the System Sends + +| Email | Sent when | App | Template path | +|---|---|---|---| +| Invitation - Hacker | User is invited as a hacker | applications | `applications/templates/mails/invitation_hacker_*` | +| Volunteer invitation | (sending code currently disabled) | applications | `applications/templates/mails/invitation_volunteer_*` | +| Mentor invitation | (sending code currently disabled) | applications | `applications/templates/mails/invitation_mentor_*` | +| Confirmation | Hacker confirms their attendance | applications | `applications/templates/mails/confirmation_*` | +| Last Reminder | Reminder before event starts | applications | `applications/templates/mails/last_reminder_*` | +| Reimbursement | Hacker is eligible for travel reimbursement | reimbursement | `reimbursement/templates/mails/reimbursement_*` | +| Reject Receipt | Submitted receipt is rejected | reimbursement | `reimbursement/templates/mails/reject_receipt_*` | +| No Reimbursement | Hacker is not eligible for reimbursement | reimbursement | `reimbursement/templates/mails/no_reimbursement_*` | +| Travel Tickets Upload | Hacker needs to upload travel tickets | reimbursement | `reimbursement/templates/mails/travel_tickets_upload_*` | +| Ticket Accepted | Travel ticket is accepted | reimbursement | `reimbursement/templates/mails/ticket_accepted_*` | +| Devpost Upload | Hacker needs to submit project to Devpost | reimbursement | `reimbursement/templates/mails/devpost_upload_*` | +| Project Invalidated | Submitted Devpost project is invalid | reimbursement | `reimbursement/templates/mails/project_invalidated_*` | +| Devpost Approved | Devpost project is approved | reimbursement | `reimbursement/templates/mails/devpost_approved_*` | +| Verify Email | User needs to verify their email address | user | `user/templates/mails/verify_email_*` | +| Password Reset | User requests password reset | user | `user/templates/mails/password_reset_*` | +| Sponsor Link | Admin shares sponsor signup link with user | user | `user/templates/mails/sponsor_link_*` | + +Shared email components (footer, buttons, images) are in `app/templates/mails/include/`. App-specific includes also live alongside their templates: `applications/templates/mails/include/` and `reimbursement/templates/mails/include/`. + +--- + +## How to Edit Email Content + +1. Find the email in the table above +2. Open the `*_message.html` file — this is the email body +3. Edit the HTML content. You can use Django template tags (`{{ variable }}`, `{% if %}`, etc.) +4. To change the subject line, edit the `*_subject.txt` file + +--- + +## Variables Available in Each Template + +Each email has a set of variables passed to it from the Python code. Here is where those variables are defined: + +| Email group | Python file | Context variables | +|---|---|---| +| Invite (hacker) | `applications/emails.py` → `create_invite_email` | `name`, `reimb`, `confirm_url`, `cancel_url`, `hybrid_option` | +| Confirmation | `applications/emails.py` → `create_confirmation_email` | `name`, `token`, `qr_url`, `cancel_url`, `is_hacker`, `is_sponsor` | +| Last reminder | `applications/emails.py` → `create_lastreminder_email` | `name`, `type`, `confirm_url`, `cancel_url`, `is_hacker`, `is_sponsor` | +| Reimbursement emails | `reimbursement/emails.py` | `app`, `reimb`, `confirm_url`, `form_url`, `cancel_url` | +| User emails | `user/emails.py` | `user`, `activate_url` (for verify email); `user`, `reset_url` (for password reset); `user`, `user_sponsor_url`, `app_sponsor_url`, `sponsor_name` (for sponsor link) | + +--- + +## How to Add a New Variable to an Email + +Example: you want to add `{{ deadline }}` to the hacker invitation email. + +### Step 1: Find the Python function that creates the email + +Open `applications/emails.py` and find `create_invite_email`. You will see a `c = { ... }` dictionary — this is the context passed to the template. + +### Step 2: Add the variable to the context dict + +```python +c = { + 'name': application.user.get_full_name, + 'reimb': getattr(application.user, 'reimbursement', None), + 'confirm_url': str(reverse('dashboard', request=request)), + 'cancel_url': str(reverse('cancel_app', request=request, kwargs={'id': application.uuid_str})), + 'hybrid_option': 'Online' if getattr(application, 'online', False) else 'Live', + 'deadline': settings.HACKATHON_END, # ← add your variable here +} +``` + +### Step 3: Use the variable in the template + +Open `applications/templates/mails/invitation_hacker_message.html` and add: + +```html +

The deadline to confirm is {{ deadline }}.

+``` + +### Step 4: Verify + +Trigger the email in local dev (or use the Django shell) and confirm the variable renders correctly. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..444d69246 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,80 @@ +# Getting Started + +myhackupc is the registration and event management tool for HackUPC. It is a Django web application used by three groups: + +- **Hackers** — apply, confirm attendance, request travel reimbursement +- **Organizers** — review applications, vote, send invites, manage check-in, hardware, meals, baggage +- **Volunteers/Mentors** — access check-in and their own application flow + +--- + +## Codebase Structure + +The project is a standard Django multi-app repository. Each feature lives in its own app folder. Here is what each active app does: + +| App | Purpose | +|---|---| +| `app/` | Core config: settings, base templates, global CSS, shared utilities, hackathon variables | +| `applications/` | Hacker, volunteer, mentor, and sponsor application forms and status flow | +| `organizers/` | Application review, voting, batch invites, waitlist/blacklist management | +| `user/` | Authentication: sign up, login, password reset, email verification, user roles | +| `reimbursement/` | Travel reimbursement requests, receipt upload, organizer approval flow | +| `checkin/` | QR-code-based event check-in for hackers, volunteers, and mentors | +| `baggage/` | Baggage drop-off and pick-up tracking | +| `hardware/` | Hardware lab lending requests | +| `meals/` | Meal distribution tracking per hacker | +| `teams/` | Team formation and membership | +| `stats/` | Organizer-facing analytics dashboards | + +Excluded from active development: `judging`, `offer`, `discord`. + +--- + +## Where Things Live + +### Templates +Each app has its own `templates/` folder. For example: +- Hacker application form: `applications/templates/` +- Organizer review interface: `organizers/templates/` +- Email templates: `/templates/mails/` in the relevant app + +### Styles +- Global layout, typography, theme colors: `app/static/css/main.css` +- Bootstrap component overrides: `app/static/css/bootstrap-overrides/` (one file per component) +- App-specific styles: each app has its own `static/css/` folder (e.g. `baggage/static/css/baggage.css`, `hardware/static/css/hw.css`) + +### URLs +| URL prefix | App | +|---|---| +| `/` | `applications` (hacker dashboard) | +| `/applications/` | `organizers` (review interface) | +| `/user/` | `user` (auth) | +| `/reimbursement/` | `reimbursement` | +| `/checkin/` | `checkin` | +| `/baggage/` | `baggage` | +| `/hardware/` | `hardware` | +| `/meals/` | `meals` | +| `/teams/` | `teams` | +| `/stats/` | `stats` | +| `/admin/` | Django admin | + +Note: `/reimbursement/`, `/baggage/`, and `/hardware/` are only registered when their respective feature flags are enabled in `app/hackathon_variables.py`. + +--- + +## The Most Important Config File + +**`app/hackathon_variables.py`** — this is what you touch every year. It contains: +- Application open/close deadlines +- Reimbursement cap amounts +- Feature flags (baggage, hardware, reimbursement, discord) +- Hackathon name, domain, email addresses +- Slack channel IDs + +--- + +## Next Steps + +- **Set up your local environment:** see `docs/setting-up.md` +- **Make a change:** see `docs/contributing.md` +- **Edit an email template:** see `docs/email-templates.md` diff --git a/docs/setting-up.md b/docs/setting-up.md new file mode 100644 index 000000000..d673791e9 --- /dev/null +++ b/docs/setting-up.md @@ -0,0 +1,87 @@ +# Setting Up myhackupc Locally + +## Prerequisites + +- Python 3.10 +- `virtualenv` + +--- + +## Installation + +```bash +git clone https://github.com/hackupc/myhackupc && cd myhackupc +virtualenv env --python=python3.10 +source ./env/bin/activate +pip install -r requirements.txt +``` + +> **Note on psycopg2-binary:** If you get an error, install openssl@3 and export LDFLAGS, CPPFLAGS, and PKG_CONFIG_PATH before running pip install. + +--- + +## Configuration + +Open `app/hackathon_variables.py` and set the required variables. You can reference the variables documented in `README.md` under "Available environment variables". + +Open `app/hackathon_variables.py` and set at minimum: +- `HACKATHON_NAME` +- `HACKATHON_DOMAIN` (use `localhost:8000` for local dev) +- Application deadlines + +--- + +## Database + +```bash +python manage.py migrate +python manage.py createsuperuser +``` + +--- + +## Running the Dev Server + +```bash +source ./env/bin/activate +python manage.py runserver +``` + +Visit http://localhost:8000. Log in with the superuser you created. + +--- + +## Environment Variables (Optional) + +| Variable | Purpose | +|----------|---------| +| **SG_KEY** | SendGrid API Key. Mandatory if you want to use SendGrid as your email backend. You can manage them [here](https://app.sendgrid.com/settings/api_keys). If not added, the system will write all emails to the filesystem for preview. | +| **PROD_MODE** | (optional) Disables Django debug mode. | +| **SECRET** | (optional) Sets web application secret. You can generate a random secret with python running: `os.urandom(24)` | +| **DATABASE_URL** | (optional) URL to connect to the database. If not set, defaults to django default SQLite database. See schema for different databases [here](https://github.com/kennethreitz/dj-database-url#url-schema). | +| **DATABASE_SECURE** | (optional) Whether or not to use SSL to connect to the database. Defaults to `true`. | +| **DOMAIN** | (optional) Domain where app will be running. Default: localhost:8000 | +| **SL_TOKEN** | (optional) Slack token to invite hackers automatically on confirmation. You can obtain it [here](https://api.slack.com/custom-integrations/legacy-tokens) | +| **SL_TEAM** | (optional) Slack team name (xxx on xxx.slack.com) | +| **DROPBOX_OAUTH2_TOKEN** | (optional) Enables DropBox as file upload server instead of local computer. | +| **SL_BOT_ID** | (optional) Slack bot ID to send messages from. | +| **SL_BOT_TOKEN** | (optional) Slack bot token to send messages. | +| **SL_BOT_CHANNEL** | (optional) General channel to refer from the bot messages. | +| **SL_BOT_DIRECTOR1** | (optional) User ID of one of the directors. | +| **SL_BOT_DIRECTOR2** | (optional) User ID of the other director. | +| **MLH_CLIENT_SECRET** | (optional) Enables MyMLH as a sign up option. Format is `client_id@client_secret` | +| **CAS_SERVER** | (optional) Enables login for other platforms | +| **GOOGLE_WALLET_APPLICATION_CREDENTIALS** | (optional) The path to the json key file containing all google-related API credentials | +| **GOOGLE_WALLET_ISSUER_ID** | (optional) The issuer ID of Google Wallet Pass API | +| **GOOGLE_WALLET_CLASS_SUFFIX** | (optional) The name of the class created at the [Google Wallet Console](https://pay.google.com/business/console/passes/) | + +--- + +## Verifying Your Changes + +After making code changes: + +1. Restart the dev server (`Ctrl+C`, then `python manage.py runserver`) +2. Navigate to the affected page in the browser +3. If you changed a model, run `python manage.py makemigrations && python manage.py migrate` +4. If you changed CSS, hard-refresh the browser (Ctrl+Shift+R / Cmd+Shift+R) diff --git a/docs/superpowers/plans/2026-04-02-codebase-refactor.md b/docs/superpowers/plans/2026-04-02-codebase-refactor.md new file mode 100644 index 000000000..0c2ab5511 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-codebase-refactor.md @@ -0,0 +1,1046 @@ +# Codebase Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reorganize the myhackupc Django codebase so that new volunteer developers can navigate it, find what they need to change, and make changes confidently without breaking things. + +**Architecture:** Split large `views.py` files into `views/` packages (one file per concern), push app-specific CSS out of global files into each app's `static/` folder, split the minified Bootstrap CSS into readable component files, and create a `docs/` folder with onboarding and how-to guides. + +**Tech Stack:** Django 3.2, Python 3.10, Bootstrap 3, WhiteNoise for static files, SendGrid for email. + +**Spec:** `docs/superpowers/specs/2026-04-02-codebase-refactor-design.md` + +--- + +## Important: How to Verify After Each Task + +This refactor does not add new behavior — every change is structural. There are no unit tests to run. Instead, use these verification steps after every task: + +- **Dev server check:** `python manage.py runserver` — should start with no import errors +- **Management command check** (only after Task 9): `python manage.py expire_applications --help` +- **CSS visual check:** Load the app in a browser and verify the page looks correct +- **Import grep:** After splitting a views file, run `grep -r "from .views" . --include="*.py"` to confirm all callers still resolve + +--- + +## Phase 1: Documentation + +--- + +### Task 1: Create `docs/getting-started.md` + +**Files:** +- Create: `docs/getting-started.md` + +- [ ] **Step 1: Create the file with this content** + +```markdown +# Getting Started + +myhackupc is the registration and event management tool for HackUPC. It is a Django web application used by three groups: + +- **Hackers** — apply, confirm attendance, request travel reimbursement +- **Organizers** — review applications, vote, send invites, manage check-in, hardware, meals, baggage +- **Volunteers/Mentors** — access check-in and their own application flow + +--- + +## Codebase Structure + +The project is a standard Django multi-app repository. Each feature lives in its own app folder. Here is what each active app does: + +| App | Purpose | +|---|---| +| `app/` | Core config: settings, base templates, global CSS, shared utilities, hackathon variables | +| `applications/` | Hacker, volunteer, mentor, and sponsor application forms and status flow | +| `organizers/` | Application review, voting, batch invites, waitlist/blacklist management | +| `user/` | Authentication: sign up, login, password reset, email verification, user roles | +| `reimbursement/` | Travel reimbursement requests, receipt upload, organizer approval flow | +| `checkin/` | QR-code-based event check-in for hackers, volunteers, and mentors | +| `baggage/` | Baggage drop-off and pick-up tracking | +| `hardware/` | Hardware lab lending requests | +| `meals/` | Meal distribution tracking per hacker | +| `teams/` | Team formation and membership | +| `stats/` | Organizer-facing analytics dashboards | + +Excluded from active development: `judging`, `offer`, `discord`. + +--- + +## Where Things Live + +### Templates +Each app has its own `templates/` folder. For example: +- Hacker application form: `applications/templates/` +- Organizer review interface: `organizers/templates/` +- Email templates: `/templates/mails/` in the relevant app + +### Styles +- Global layout, typography, theme colors: `app/static/css/main.css` +- Bootstrap component overrides: `app/static/css/bootstrap-overrides/` (one file per component) +- App-specific styles: `/static/css/.css` (e.g. `baggage/static/css/baggage.css`) + +### URLs +| URL prefix | App | +|---|---| +| `/` | `applications` (hacker dashboard) | +| `/applications/` | `organizers` (review interface) | +| `/user/` | `user` (auth) | +| `/reimbursement/` | `reimbursement` | +| `/checkin/` | `checkin` | +| `/baggage/` | `baggage` | +| `/hardware/` | `hardware` | +| `/meals/` | `meals` | +| `/teams/` | `teams` | +| `/stats/` | `stats` | +| `/admin/` | Django admin | + +--- + +## The Most Important Config File + +**`app/hackathon_variables.py`** — this is what you touch every year. It contains: +- Application open/close deadlines +- Reimbursement cap amounts +- Feature flags (baggage, hardware, reimbursement, discord) +- Hackathon name, domain, email addresses +- Slack channel IDs + +A template with all available variables is at `app/hackathon_variables.py.template`. + +--- + +## Next Steps + +- **Set up your local environment:** see `docs/setting-up.md` +- **Make a change:** see `docs/contributing.md` +- **Edit an email template:** see `docs/email-templates.md` +``` + +- [ ] **Step 2: Verify the file was created** + +```bash +ls docs/ +``` + +--- + +### Task 2: Create `docs/setting-up.md` and update README + +**Files:** +- Create: `docs/setting-up.md` +- Modify: `README.md` (add pointer to docs at the top of the Setup section) + +- [ ] **Step 1: Create `docs/setting-up.md`** + +Extract the setup instructions from `README.md` (the "Setup", "Available environment variables", "Server > Local environment", and "Dummy data" sections) and write them here in a single cohesive guide. Use this structure: + +```markdown +# Setting Up myhackupc Locally + +## Prerequisites + +- Python 3.10 +- `virtualenv` + +--- + +## Installation + +```bash +git clone https://github.com/hackupc/myhackupc && cd myhackupc +virtualenv env --python=python3.10 +source ./env/bin/activate +pip install -r requirements.txt +``` + +> **Note on psycopg2-binary:** If you get an error, install openssl@3 and export LDFLAGS, CPPFLAGS, and PKG_CONFIG_PATH before running pip install. + +--- + +## Configuration + +Copy the hackathon variables template and fill in the values: + +```bash +cp app/hackathon_variables.py.template app/hackathon_variables.py +``` + +Open `app/hackathon_variables.py` and set at minimum: +- `HACKATHON_NAME` +- `HACKATHON_DOMAIN` (use `localhost:8000` for local dev) +- Application deadlines + +--- + +## Database + +```bash +python manage.py migrate +python manage.py createsuperuser +``` + +--- + +## Running the Dev Server + +```bash +source ./env/bin/activate +python manage.py runserver +``` + +Visit http://localhost:8000. Log in with the superuser you created. + +--- + +## Environment Variables (Optional) + +| Variable | Purpose | +|---|---| +| `SG_KEY` | SendGrid API key. If not set, emails are written to the filesystem at `sent_emails/` | +| `PROD_MODE` | Disables Django debug mode | +| `SECRET` | Web app secret key | +| `DATABASE_URL` | Database connection URL. Defaults to SQLite | +| `SL_TOKEN` | Slack token for automatic invites on confirmation | +| `DROPBOX_OAUTH2_TOKEN` | Enables Dropbox as file storage backend | +| `MLH_CLIENT_SECRET` | Enables MyMLH sign-up (`app_id@secret` format) | +| `CAS_SERVER` | Enables CAS login for other platforms | + +See `README.md` for the full list and Heroku/production deployment instructions. + +--- + +## Verifying Your Changes + +After making code changes: + +1. Restart the dev server (`Ctrl+C`, then `python manage.py runserver`) +2. Navigate to the affected page in the browser +3. If you changed a model, run `python manage.py makemigrations && python manage.py migrate` +4. If you changed CSS, hard-refresh the browser (Ctrl+Shift+R / Cmd+Shift+R) +``` + +- [ ] **Step 2: Add a pointer to `docs/` at the top of the Setup section in `README.md`** + +Find the `## Setup` heading in `README.md` and add this line immediately after it: + +```markdown +> For a step-by-step guide see [docs/setting-up.md](docs/setting-up.md). For codebase orientation see [docs/getting-started.md](docs/getting-started.md). +``` + +- [ ] **Step 3: Verify dev server still starts** + +```bash +python manage.py runserver +``` + +Expected: starts without errors. + +--- + +### Task 3: Create `docs/contributing.md` + +**Files:** +- Create: `docs/contributing.md` + +- [ ] **Step 1: Create the file with this content** + +```markdown +# Contributing — Common Changes + +This document walks through the most common yearly changes with exact file paths. + +--- + +## Example 1: Adding or Removing a Field from the Hacker Application + +The hacker application form is split across three layers: model, form, and template. + +### 1. Update the model + +File: `applications/models/hacker.py` + +Add or remove the field from the `HackerApplication` model class. Use standard Django field types. Example: + +```python +# Adding a new field +dietary_restrictions = models.CharField( + max_length=200, + blank=True, + help_text="Any dietary restrictions we should know about?" +) +``` + +### 2. Create and run a migration + +```bash +python manage.py makemigrations +python manage.py migrate +``` + +### 3. Update the form + +File: `applications/forms/hacker.py` + +Add the field name to the `fields` list in `HackerApplicationForm.Meta`, and optionally customize its widget or label in the `widgets` or `labels` dicts. + +### 4. Update the template + +File: `applications/templates/include/application_form.html` (or the relevant template — look for the `{% for field in form %}` loop or the specific field rendering) + +If you need custom layout for the new field, add it explicitly. Otherwise it will be rendered automatically by the form loop. + +### 5. Verify + +Start the dev server, go to the hacker application form, and confirm the new field appears. + +--- + +## Example 2: Adding or Removing a Field from the Volunteer or Mentor Application + +Same pattern as the hacker application, different files: + +| Layer | Volunteer | Mentor | +|---|---|---| +| Model | `applications/models/volunteer.py` | `applications/models/mentor.py` | +| Form | `applications/forms/volunteer.py` | `applications/forms/mentor.py` | +| Template | `applications/templates/` (look for volunteer/mentor form template) | same | + +Run `python manage.py makemigrations && python manage.py migrate` after changing the model. + +--- + +## Example 3: Changing the Travel Reimbursement Cap + +File: `app/hackathon_variables.py` + +Find and update the reimbursement limit variables. No migration or code change needed — these are config values read at runtime. + +--- + +## Where to Look for Other Things + +| What you want to change | Where to look | +|---|---| +| Application status flow (pending → invited → confirmed) | `applications/models/base.py` | +| Who can review applications and vote | `organizers/views/review.py` | +| Batch invite algorithm | `organizers/views/batch_ops.py` | +| Email content | See `docs/email-templates.md` | +| Check-in logic | `checkin/views.py` | +| User roles and permissions | `user/models.py`, `user/mixins.py` | +| Hackathon name, dates, deadlines | `app/hackathon_variables.py` | +| Global styles | `app/static/css/main.css` | +| Bootstrap component styles | `app/static/css/bootstrap-overrides/` | +``` + +--- + +### Task 4: Create `docs/email-templates.md` + +**Files:** +- Create: `docs/email-templates.md` + +- [ ] **Step 1: Create the file with this content** + +````markdown +# Email Templates + +myhackupc sends emails using Django's template system. Each email consists of two template files: +- `*_subject.txt` — the subject line (one line of text, no HTML) +- `*_message.html` — the email body (HTML) + +--- + +## All Emails the System Sends + +| Email | Sent when | App | Template path | +|---|---|---|---| +| Hacker invitation | Organizer sends batch invites | `applications` | `applications/templates/mails/invitation_hacker_*` | +| Mentor invitation | (currently disabled) | `applications` | `applications/templates/mails/invitation_mentor_*` | +| Volunteer invitation | (currently disabled) | `applications` | `applications/templates/mails/invitation_volunteer_*` | +| Confirmation (ticket) | Hacker confirms attendance | `applications` | `applications/templates/mails/confirmation_*` | +| Last reminder | Reminder before confirmation deadline | `applications` | `applications/templates/mails/last_reminder_*` | +| Reimbursement approved | Organizer approves reimbursement | `reimbursement` | `reimbursement/templates/mails/reimbursement_*` | +| Receipt rejected | Organizer rejects uploaded receipt | `reimbursement` | `reimbursement/templates/mails/reject_receipt_*` | +| No reimbursement | Hacker not eligible for reimbursement | `reimbursement` | `reimbursement/templates/mails/no_reimbursement_*` | +| Travel tickets upload | Ask hacker to upload travel receipts | `reimbursement` | `reimbursement/templates/mails/travel_tickets_upload_*` | +| Ticket accepted | Receipt accepted | `reimbursement` | `reimbursement/templates/mails/ticket_accepted_*` | +| Devpost upload | Ask hacker to upload Devpost link | `reimbursement` | `reimbursement/templates/mails/devpost_upload_*` | +| Project invalidated | Devpost project rejected | `reimbursement` | `reimbursement/templates/mails/project_invalidated_*` | +| Devpost approved | Devpost project approved | `reimbursement` | `reimbursement/templates/mails/devpost_approved_*` | +| Email verification | User registers | `user` | `user/templates/mails/verify_email_*` | +| Password reset | User requests password reset | `user` | `user/templates/mails/password_reset_*` | +| Sponsor link | Sponsor account linked | `user` | `user/templates/mails/sponsor_link_*` | + +Shared email components (footer, buttons, images) are in `app/templates/mails/include/`. + +--- + +## How to Edit Email Content + +1. Find the email in the table above +2. Open the `*_message.html` file — this is the email body +3. Edit the HTML content. You can use Django template tags (`{{ variable }}`, `{% if %}`, etc.) +4. To change the subject line, edit the `*_subject.txt` file + +--- + +## Variables Available in Each Template + +Each email has a set of variables passed to it from the Python code. Here is where those variables are defined: + +| Email group | Python file | Context variables | +|---|---|---| +| Application emails (invite, confirmation, reminder) | `applications/emails.py` | `name`, `reimb`, `confirm_url`, `cancel_url`, `hybrid_option`, `token`, `qr_url` | +| Reimbursement emails | `reimbursement/emails.py` | `app`, `reimb`, `confirm_url`, `form_url`, `cancel_url` | +| User emails | `user/emails.py` | varies per email — read the function | + +--- + +## How to Add a New Variable to an Email + +Example: you want to add `{{ deadline }}` to the hacker invitation email. + +### Step 1: Find the Python function that creates the email + +Open `applications/emails.py` and find `create_invite_email`. You will see a `c = { ... }` dictionary — this is the context passed to the template. + +### Step 2: Add the variable to the context dict + +```python +c = { + 'name': application.user.get_full_name, + 'reimb': getattr(application.user, 'reimbursement', None), + 'confirm_url': str(reverse('dashboard', request=request)), + 'cancel_url': str(reverse('cancel_app', request=request, kwargs={'id': application.uuid_str})), + 'hybrid_option': 'Online' if getattr(application, 'online', False) else 'Live', + 'deadline': settings.HACKATHON_END, # ← add your variable here +} +``` + +### Step 3: Use the variable in the template + +Open `applications/templates/mails/invitation_hacker_message.html` and add: + +```html +

The deadline to confirm is {{ deadline }}.

+``` + +### Step 4: Verify + +Trigger the email in local dev (or use the Django shell) and confirm the variable renders correctly. +```` + +--- + +## Phase 2: CSS Reorganization + +--- + +### Task 5: Split `custom-bootstrap.css` into component files + +**Files:** +- Modify: `app/templates/base.html` (replace single `` with multiple) +- Create: `app/static/css/bootstrap-overrides/variables.css` +- Create: `app/static/css/bootstrap-overrides/print.css` +- Create: `app/static/css/bootstrap-overrides/glyphicons.css` +- Create: `app/static/css/bootstrap-overrides/scaffolding.css` +- Create: `app/static/css/bootstrap-overrides/typography.css` +- Create: `app/static/css/bootstrap-overrides/grid.css` +- Create: `app/static/css/bootstrap-overrides/tables.css` +- Create: `app/static/css/bootstrap-overrides/forms.css` +- Create: `app/static/css/bootstrap-overrides/buttons.css` +- Create: `app/static/css/bootstrap-overrides/nav.css` +- Create: `app/static/css/bootstrap-overrides/dropdowns.css` +- Create: `app/static/css/bootstrap-overrides/alerts.css` +- Create: `app/static/css/bootstrap-overrides/modals.css` +- Create: `app/static/css/bootstrap-overrides/misc.css` + +- [ ] **Step 1: Un-minify `custom-bootstrap.css`** + +The file is currently minified (all on a few lines). Before splitting, format it so each rule is on its own line: + +```bash +# Install prettier if not available +npm install -g prettier +# Format the file in-place +prettier --write app/static/css/custom-bootstrap.css --parser css +``` + +If npm is not available, paste the file contents into https://www.cleancss.com/css-beautify/ and replace the file. The un-minified version will be easier to split and maintain going forward. + +- [ ] **Step 2: Create `app/static/css/bootstrap-overrides/` directory** + +```bash +mkdir -p app/static/css/bootstrap-overrides +``` + +- [ ] **Step 3: Split the file into component files** + +Open `app/static/css/custom-bootstrap.css`. Working from top to bottom, cut each Bootstrap component section into its own file. Use the following CSS class names to identify section boundaries: + +| File | Starts at / contains | +|---|---| +| `variables.css` | `html {`, `body {`, base resets at the top of the file | +| `print.css` | `@media print {` | +| `glyphicons.css` | `.glyphicon` | +| `scaffolding.css` | `.container`, `.container-fluid`, row/col classes | +| `typography.css` | `h1,h2,h3`, `.lead`, `.small`, `blockquote`, `code`, `pre` | +| `grid.css` | `.col-xs-`, `.col-sm-`, `.col-md-`, `.col-lg-` | +| `tables.css` | `.table` | +| `forms.css` | `.form-group`, `.form-control`, `.input-group`, `.checkbox`, `.radio` | +| `buttons.css` | `.btn`, `.btn-default`, `.btn-primary` etc. | +| `nav.css` | `.nav`, `.navbar`, `.breadcrumb`, `.pagination`, `.pager`, `.tabs` | +| `dropdowns.css` | `.dropdown`, `.caret`, `.open` | +| `alerts.css` | `.alert`, `.label`, `.badge` | +| `modals.css` | `.modal`, `.fade`, `.in`, `.backdrop` | +| `misc.css` | Everything else (panels, wells, thumbnails, progress bars, list-groups, jumbotron, close, etc.) | + +Each file should start with a comment: +```css +/* Bootstrap 3 overrides — */ +``` + +- [ ] **Step 4: Update `base.html` to load the component files** + +In `app/templates/base.html`, find line 53: +```html + +``` + +Replace it with one `` per component file, **in the same order** as the sections appeared in the original file: + +```html + + + + + + + + + + + + + + +``` + +- [ ] **Step 5: Collect static files and visual check** + +```bash +python manage.py collectstatic --noinput +python manage.py runserver +``` + +Open the app in a browser. The page should look exactly the same as before. Check: +- Navbar renders correctly +- Buttons have correct colours and style +- Forms look correct +- Any modal dialogs (try opening one) render correctly + +If anything looks broken, the cascade order is off — check that the component files are loaded in the same order the sections appeared in the original file. + +--- + +### Task 6: Audit `main.css` and extract app-specific styles + +**Files:** +- Modify: `app/static/css/main.css` (remove app-specific styles) +- Modify (create if needed): `organizers/static/css/organizers.css` +- Modify (create if needed): `reimbursement/static/css/reimbursement.css` + +- [ ] **Step 1: Audit `main.css`** + +Read through `app/static/css/main.css` (506 lines). For each CSS rule, ask: "Is this only used in one app's templates?" If yes, it belongs in that app's CSS file. If it's used globally (body, navbar, base layout, typography), it stays. + +Keep in `main.css`: +- Body background, font, base layout +- Navbar styles +- Footer styles +- Global utility classes used across multiple apps + +Move to app-specific files: +- Any class that only appears in templates of a single app (search with `grep -r "class-name" /templates/`) + +- [ ] **Step 2: Create app CSS files if needed and move the styles** + +If you find styles that belong only to `organizers`, create `organizers/static/css/organizers.css` and move them there. Same for `reimbursement`. + +- [ ] **Step 3: Add the CSS link to the app's base template if needed** + +For any app that gets a new CSS file, find its base template (e.g. `organizers/templates/organizers_base.html` or whichever template that app's views extend). Add a `{% block extra_head %}` block: + +```html +{% block extra_head %} + +{% endblock %} +``` + +**Important:** Templates that extend `base_tabs.html` use `{% block extra_head %}`. Templates that extend `base.html` directly use `{% block head %}`. Check which one the template extends before adding the block. + +- [ ] **Step 4: Verify** + +```bash +python manage.py runserver +``` + +Visual check: navigate to affected pages and confirm styles are unchanged. + +--- + +## Phase 3: Views Splitting + +**Pattern for every app:** Convert `views.py` → `views/` package with sub-files and an `__init__.py` that re-exports everything. No `urls.py` changes needed. + +--- + +### Task 7: Split `organizers/views.py` + +**Files:** +- Delete: `organizers/views.py` +- Create: `organizers/views/__init__.py` +- Create: `organizers/views/lists.py` +- Create: `organizers/views/review.py` +- Create: `organizers/views/batch_ops.py` + +**Class/function → file mapping:** + +`lists.py`: +- `hacker_tabs`, `volunteer_tabs`, `mentor_tabs`, `sponsor_tabs` (tab helper functions used throughout the file) +- `ApplicationsListView` +- `InviteListView` +- `WaitlistedApplicationsListView` +- `DubiousApplicationsListView` +- `BlacklistApplicationsListView` +- `_OtherApplicationsListView` +- `VolunteerApplicationsListView` +- `SponsorApplicationsListView` +- `SponsorUserListView` +- `MentorApplicationsListView` + +`review.py`: +- `add_vote`, `add_comment` (helper functions) +- `ApplicationDetailView` +- `ReviewApplicationView` +- `ReviewApplicationDetailView` +- `ReviewVolunteerApplicationView` +- `ReviewSponsorApplicationView` +- `ReviewMentorApplicationView` +- `ReviewResume` +- `VisualizeResume` + +`batch_ops.py`: +- `InviteTeamListView` (contains the core batch invite algorithm) + +- [ ] **Step 1: Create `organizers/views/` directory** + +```bash +mkdir organizers/views +``` + +- [ ] **Step 2: Create `organizers/views/lists.py`** + +Copy the imports from the top of `organizers/views.py` — all of them, because you will prune unused ones later. Then cut-paste the tab helper functions and all list view classes listed above. + +At the top of `lists.py`, add any imports needed by these classes. (Start with all imports from the original file — remove what is not used once the split is complete.) + +- [ ] **Step 3: Create `organizers/views/review.py`** + +Same process: copy all imports, cut-paste `add_vote`, `add_comment`, and all review view classes listed above. + +Note: `ReviewApplicationView` and `ReviewApplicationDetailView` both inherit from `ApplicationDetailView`, which is also in this file — no cross-file import needed. + +- [ ] **Step 4: Create `organizers/views/batch_ops.py`** + +Copy all imports, cut-paste `InviteTeamListView`. + +- [ ] **Step 5: Create `organizers/views/__init__.py`** + +This file re-exports every name that any file in the project imports from `organizers.views`. The following names are confirmed callers (grep verified): + +```python +from organizers.views.lists import ( + hacker_tabs, + volunteer_tabs, + mentor_tabs, + sponsor_tabs, + ApplicationsListView, + InviteListView, + WaitlistedApplicationsListView, + DubiousApplicationsListView, + BlacklistApplicationsListView, + _OtherApplicationsListView, + VolunteerApplicationsListView, + SponsorApplicationsListView, + SponsorUserListView, + MentorApplicationsListView, +) +from organizers.views.review import ( + add_vote, + add_comment, + ApplicationDetailView, + ReviewApplicationView, + ReviewApplicationDetailView, + ReviewVolunteerApplicationView, + ReviewSponsorApplicationView, + ReviewMentorApplicationView, + ReviewResume, + VisualizeResume, +) +from organizers.views.batch_ops import ( + InviteTeamListView, +) + +__all__ = [ + 'hacker_tabs', 'volunteer_tabs', 'mentor_tabs', 'sponsor_tabs', + 'ApplicationsListView', 'InviteListView', 'WaitlistedApplicationsListView', + 'DubiousApplicationsListView', 'BlacklistApplicationsListView', + '_OtherApplicationsListView', 'VolunteerApplicationsListView', + 'SponsorApplicationsListView', 'SponsorUserListView', 'MentorApplicationsListView', + 'add_vote', 'add_comment', + 'ApplicationDetailView', 'ReviewApplicationView', 'ReviewApplicationDetailView', + 'ReviewVolunteerApplicationView', 'ReviewSponsorApplicationView', + 'ReviewMentorApplicationView', 'ReviewResume', 'VisualizeResume', + 'InviteTeamListView', +] +``` + +- [ ] **Step 6: Delete the original `organizers/views.py`** + +```bash +rm organizers/views.py +``` + +- [ ] **Step 7: Verify all callers are satisfied** + +```bash +grep -r "from organizers.views" . --include="*.py" +grep -r "from organizers import views" . --include="*.py" +``` + +Confirm every import resolves to a name that `__init__.py` exports. + +- [ ] **Step 8: Start the dev server** + +```bash +python manage.py runserver +``` + +Expected: starts with no import errors. Navigate to `/applications/` (organizer view) and confirm it loads. + +--- + +### Task 8: Split `reimbursement/views.py` + +**Files:** +- Delete: `reimbursement/views.py` +- Create: `reimbursement/views/__init__.py` +- Create: `reimbursement/views/hacker.py` +- Create: `reimbursement/views/organizer.py` + +**Class → file mapping:** + +`hacker.py`: +- `ReimbursementHacker` + +`organizer.py`: +- `ReimbursementDetail` +- `ReceiptReview` (inherits from `ReimbursementDetail` — both must be in the same file) +- `ReimbursementListView` +- `SendReimbursementListView` + +- [ ] **Step 1: Create `reimbursement/views/` directory and sub-files** + +Follow the same pattern as Task 7: create the directory, create `hacker.py` and `organizer.py` with the relevant classes, create `__init__.py` with re-exports, delete the original `views.py`. + +- [ ] **Step 2: Create `reimbursement/views/__init__.py`** + +```python +from reimbursement.views.hacker import ReimbursementHacker +from reimbursement.views.organizer import ( + ReimbursementDetail, + ReceiptReview, + ReimbursementListView, + SendReimbursementListView, +) + +__all__ = [ + 'ReimbursementHacker', + 'ReimbursementDetail', 'ReceiptReview', + 'ReimbursementListView', 'SendReimbursementListView', +] +``` + +- [ ] **Step 3: Verify callers and start dev server** + +```bash +grep -r "from reimbursement.views" . --include="*.py" +grep -r "from reimbursement import views" . --include="*.py" +python manage.py runserver +``` + +--- + +### Task 9: Split `applications/views.py` + +**Files:** +- Delete: `applications/views.py` +- Create: `applications/views/__init__.py` +- Create: `applications/views/hacker.py` +- Create: `applications/views/sponsor.py` +- Create: `applications/views/mentor.py` +- Create: `applications/views/draft.py` + +**Class/function → file mapping:** + +`hacker.py`: +- `VIEW_APPLICATION_TYPE` (constant — also imported by management command) +- `VIEW_APPLICATION_FORM_TYPE` (constant) +- `check_application_exists` (helper) +- `get_deadline` (helper) +- `user_is_in_blacklist` (helper) +- `ConfirmApplication` +- `CancelApplication` +- `HackerDashboard` +- `HackerApplication` + +`sponsor.py`: +- `SponsorApplicationView` +- `SponsorDashboard` + +`mentor.py`: +- `ConvertHackerToMentor` + +`draft.py`: +- `save_draft` + +- [ ] **Step 1: Create `applications/views/` directory and sub-files** + +Follow the same pattern. `hacker.py` will contain most of the code. + +- [ ] **Step 2: Create `applications/views/__init__.py`** + +```python +from applications.views.hacker import ( + VIEW_APPLICATION_TYPE, + VIEW_APPLICATION_FORM_TYPE, + check_application_exists, + get_deadline, + user_is_in_blacklist, + ConfirmApplication, + CancelApplication, + HackerDashboard, + HackerApplication, +) +from applications.views.sponsor import SponsorApplicationView, SponsorDashboard +from applications.views.mentor import ConvertHackerToMentor +from applications.views.draft import save_draft + +__all__ = [ + 'VIEW_APPLICATION_TYPE', 'VIEW_APPLICATION_FORM_TYPE', + 'check_application_exists', 'get_deadline', 'user_is_in_blacklist', + 'ConfirmApplication', 'CancelApplication', 'HackerDashboard', 'HackerApplication', + 'SponsorApplicationView', 'SponsorDashboard', + 'ConvertHackerToMentor', + 'save_draft', +] +``` + +- [ ] **Step 3: Verify callers including the management command** + +```bash +grep -r "from applications.views" . --include="*.py" +grep -r "from applications import views" . --include="*.py" +python manage.py runserver +``` + +- [ ] **Step 4: Verify the management command imports cleanly** + +```bash +python manage.py expire_applications --help +``` + +Expected: prints the help text. If you get an ImportError, `VIEW_APPLICATION_TYPE` is not being re-exported correctly from `__init__.py`. + +--- + +### Task 10: Split `user/views.py` + +**Files:** +- Delete: `user/views.py` +- Create: `user/views/__init__.py` +- Create: `user/views/authentication.py` +- Create: `user/views/profile.py` + +**Important:** `user/auth.py` already exists as a CAS authentication backend. Do NOT name the views file `auth.py` — use `authentication.py`. + +**Class/function → file mapping:** + +`authentication.py`: +- `login`, `signup`, `Logout`, `activate` +- `password_reset`, `password_reset_confirm`, `password_reset_complete`, `password_reset_done` +- `verify_email_required`, `set_password`, `send_email_verification` +- `callback` +- `SponsorRegister` + +`profile.py`: +- `UserProfile` +- `DeleteAccount` + +- [ ] **Step 1: Create `user/views/` directory and sub-files** + +Follow the same pattern. + +- [ ] **Step 2: Create `user/views/__init__.py`** + +```python +from user.views.authentication import ( + login, signup, Logout, activate, + password_reset, password_reset_confirm, password_reset_complete, password_reset_done, + verify_email_required, set_password, send_email_verification, + callback, SponsorRegister, +) +from user.views.profile import UserProfile, DeleteAccount + +__all__ = [ + 'login', 'signup', 'Logout', 'activate', + 'password_reset', 'password_reset_confirm', 'password_reset_complete', 'password_reset_done', + 'verify_email_required', 'set_password', 'send_email_verification', + 'callback', 'SponsorRegister', + 'UserProfile', 'DeleteAccount', +] +``` + +- [ ] **Step 3: Verify callers and start dev server** + +```bash +grep -r "from user.views" . --include="*.py" +grep -r "from user import views" . --include="*.py" +python manage.py runserver +``` + +--- + +### Task 11: Split `baggage/views.py` + +**Files:** +- Delete: `baggage/views.py` +- Create: `baggage/views/__init__.py` +- Create: `baggage/views/volunteer.py` +- Create: `baggage/views/hacker.py` + +**Class/function → file mapping:** + +`volunteer.py`: +- `baggage_checkIn`, `baggage_checkOut` (admin operations) +- `organizer_tabs` +- `BaggageList`, `BaggageHacker`, `BaggageUsers` +- `BaggageAdd`, `BaggageDetail`, `BaggageMap`, `BaggageHistory` +- `BaggageAPI` + +`hacker.py`: +- `hacker_tabs` +- `BaggageCurrentHacker` + +- [ ] **Step 1: Create sub-files and `__init__.py`, delete original** + +Follow the same pattern as Task 7. + +- [ ] **Step 2: Create `baggage/views/__init__.py`** + +```python +from baggage.views.volunteer import ( + baggage_checkIn, + baggage_checkOut, + organizer_tabs, + BaggageList, + BaggageHacker, + BaggageUsers, + BaggageAdd, + BaggageDetail, + BaggageMap, + BaggageHistory, + BaggageAPI, +) +from baggage.views.hacker import hacker_tabs, BaggageCurrentHacker + +__all__ = [ + 'baggage_checkIn', 'baggage_checkOut', 'organizer_tabs', + 'BaggageList', 'BaggageHacker', 'BaggageUsers', + 'BaggageAdd', 'BaggageDetail', 'BaggageMap', 'BaggageHistory', + 'BaggageAPI', + 'hacker_tabs', 'BaggageCurrentHacker', +] +``` + +- [ ] **Step 3: Verify callers and start dev server** + +```bash +grep -r "from baggage.views" . --include="*.py" +grep -r "from baggage import views" . --include="*.py" +python manage.py runserver +``` + +--- + +### Task 12: Split `hardware/views.py` + +**Files:** +- Delete: `hardware/views.py` +- Create: `hardware/views/__init__.py` +- Create: `hardware/views/hacker.py` +- Create: `hardware/views/admin.py` + +**Class/function → file mapping:** + +`hacker.py`: +- `HardwareBorrowingsView` +- `HardwareListView` + +`admin.py`: +- `hardware_tabs` +- `HardwareAdminRequestsView` +- `HardwareAdminView` + +- [ ] **Step 1: Create sub-files and `__init__.py`, delete original** + +Follow the same pattern as Task 7. + +- [ ] **Step 2: Create `hardware/views/__init__.py`** + +```python +from hardware.views.hacker import HardwareBorrowingsView, HardwareListView +from hardware.views.admin import hardware_tabs, HardwareAdminRequestsView, HardwareAdminView + +__all__ = [ + 'HardwareBorrowingsView', 'HardwareListView', + 'hardware_tabs', 'HardwareAdminRequestsView', 'HardwareAdminView', +] +``` + +- [ ] **Step 3: Verify callers and start dev server** + +```bash +grep -r "from hardware.views" . --include="*.py" +grep -r "from hardware import views" . --include="*.py" +python manage.py runserver +``` + +- [ ] **Step 3: Final end-to-end check** + +With all views split, do a final check: + +```bash +python manage.py runserver +``` + +Navigate through the main flows in the browser: +- Hacker dashboard (`/`) +- Organizer application list (`/applications/`) +- Reimbursement dashboard (`/reimbursement/`) +- Check-in interface (`/checkin/`) +- Stats dashboard (`/stats/`) + +All should load without errors. diff --git a/docs/superpowers/specs/2026-04-02-codebase-refactor-design.md b/docs/superpowers/specs/2026-04-02-codebase-refactor-design.md new file mode 100644 index 000000000..dbdda8f68 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codebase-refactor-design.md @@ -0,0 +1,192 @@ +# Codebase Refactor Design — myhackupc +**Date:** 2026-04-02 +**Author:** Gerard Madrid +**Status:** Approved + +--- + +## Goal + +Make the myhackupc codebase maintainable for a volunteer dev team where members are new each year and unfamiliar with the project. The refactor moves things around and splits files — it does **not** improve or rewrite any logic. + +--- + +## Scope + +**Included apps:** `applications`, `organizers`, `reimbursement`, `user`, `checkin`, `baggage`, `hardware`, `meals`, `teams`, `stats`, `app` (core) + +**Excluded apps (stable, left untouched):** `judging`, `offer`, `discord` + +--- + +## Part 1: Views Splitting + +### Principle +Any `views.py` with multiple unrelated concerns becomes a `views/` package. The `views/__init__.py` re-exports **every name** that any other module in the project imports from that views module — not just names referenced in `urls.py`. This is the primary safety guarantee. + +Note: Django's URL files typically do `from app import views` and then call `views.SomeClass.as_view()`. As long as `__init__.py` exposes `SomeClass`, Django's URL machinery is unaffected regardless of which sub-file defines it. + +### `organizers/views.py` (1,136 lines) → `organizers/views/` package + +**Split by class/function (explicit assignments):** + +| Name | Target file | Notes | +|---|---|---| +| All `*ListView` classes (hacker, volunteer, mentor, sponsor, dubious, blacklist, waitlist) | `lists.py` | | +| `_OtherApplicationsListView` | `lists.py` | Private base class for volunteer/sponsor/mentor lists; must be in same file as its subclasses | +| `ApplicationDetailView` | `review.py` | Base class — must be in same file as `ReviewApplicationView` which inherits from it | +| `ReviewApplicationView` | `review.py` | | +| Vote and comment helper functions | `review.py` | | +| `InviteTeamListView` | `batch_ops.py` | It is a list view by name but contains the core batch invite algorithm — belongs here | +| Batch email sending orchestration | `batch_ops.py` | Note: the actual `send_batch_emails` function lives in `applications/emails.py`; organizers only has the orchestration | + +**`__init__.py` must re-export these specific names** (cross-app imports confirmed in codebase): +- All public view classes (for `urls.py`) +- `_OtherApplicationsListView` — imported by `applications/views.py` +- `hacker_tabs`, `volunteer_tabs`, `mentor_tabs`, `sponsor_tabs` — imported by `checkin/views.py` and `reimbursement/views.py` + +### `applications/views.py` (500 lines) → `applications/views/` package + +| File | Contents | +|---|---| +| `hacker.py` | Hacker application create/edit/cancel/confirm; shared helpers (`VIEW_APPLICATION_TYPE`, `check_application_exists`, etc.) | +| `mentor.py` | `ConvertHackerToMentor` | +| `sponsor.py` | `SponsorApplicationView`, `SponsorDashboard` | +| `draft.py` | Draft auto-save logic | +| `__init__.py` | Re-exports all view classes and constants | + +Note: there are no volunteer-specific view classes in `applications/views.py` — volunteers use the hacker application flow with role detection. No `volunteer.py` is created. + +**`__init__.py` must re-export:** +- All public view classes (for `urls.py`) +- `VIEW_APPLICATION_TYPE` — imported by `applications/management/commands/expire_applications.py` +- `VIEW_APPLICATION_FORM_TYPE` — companion constant in the same file; re-export for safety + +### Other active apps → `views/` packages + +| App | First file | Second file | +|---|---|---| +| `reimbursement` | `hacker.py` — request and receipt upload flow | `organizer.py` — approval and bulk ops. Note: `ReceiptReview` inherits from `ReimbursementDetail`; both must land in the same file (`organizer.py`) | +| `user` | `auth.py` — login, signup, password reset, email verification | `profile.py` — profile editing, account management. Note: `user/auth.py` already exists as a CAS authentication backend — the new views file must be named differently, e.g. `authentication.py` | +| `baggage` | `hacker.py` — hacker-facing baggage flow | `volunteer.py` — volunteer management interface | +| `hardware` | `hacker.py` — hacker request flow | `admin.py` — inventory management | + +### Left as single files (small or single concern) +- `checkin/views.py` (263 lines) +- `meals/views.py` (245 lines) +- `teams/views.py` (74 lines) +- `stats/views.py` (381 lines — large but single concern: analytics) + +--- + +## Part 2: CSS Reorganization + +### Principle +Styles for a feature live next to that feature's code. If a style only applies to one app, it belongs in that app's `static/css/` folder. + +### `custom-bootstrap.css` (1,496 lines) → `bootstrap-overrides/` component files + +Split into the following files under `app/static/css/bootstrap-overrides/`, **in this order** to preserve cascade: + +| File | Contents | +|---|---| +| `variables.css` | Colors, fonts, spacing resets | +| `print.css` | `@media print` rules | +| `glyphicons.css` | Glyphicon icon font | +| `scaffolding.css` | `.container`, `.container-fluid`, row/col base | +| `typography.css` | Headings, paragraphs, links, blockquote, code | +| `grid.css` | `.col-xs-*`, `.col-sm-*`, `.col-md-*`, `.col-lg-*` | +| `tables.css` | Table overrides | +| `forms.css` | Input, select, textarea overrides | +| `buttons.css` | Button styles and variants | +| `nav.css` | Navbar, tabs, breadcrumbs, pagination | +| `dropdowns.css` | Dropdown menus | +| `alerts.css` | Alert and badge styles | +| `modals.css` | Modal dialogs | +| `misc.css` | Everything else (panels, wells, progress bars, etc.) | + +`base.html` gets one `` tag per file, **in the same order as the original file sections**, preserving cascade order. + +### `main.css` (506 lines) +Audit for app-specific selectors. Any styles scoped to a specific feature are extracted to that app's `static/css/` file. What remains in `main.css` is global layout, typography baseline, theme colors, navbar, footer only. + +### App-specific CSS — existing files (correct, no change needed) +- `baggage/static/css/baggage.css` +- `hardware/static/css/hw.css` +- `applications/static/css/profile.css` — **cross-app note:** this file is also loaded by organizers and reimbursement templates. It is a shared stylesheet, not applications-only. Do not move it into the applications app folder; leave it where it is. +- `checkin/static/css/checkin.css` — already exists and already loaded by checkin templates + +### App-specific CSS — may need files created +If `main.css` contains styles for these apps, extract them: +- `reimbursement/static/css/reimbursement.css` +- `organizers/static/css/organizers.css` + +### Adding CSS to a template +Templates that extend `base_tabs.html` use `{% block extra_head %}` to add per-page stylesheets. +Templates that extend `base.html` directly use `{% block head %}`. +Do **not** use `{% block extra_css %}` — that block does not exist in this codebase. + +--- + +## Part 3: Documentation + +New `docs/` folder at the repo root. The existing README gets a note pointing to both `docs/getting-started.md` and `docs/setting-up.md`. + +### `docs/getting-started.md` +- What myhackupc is and who uses it (organizers, hackers, volunteers, mentors) +- Active Django apps: one sentence per app explaining its purpose +- Key config file: `app/hackathon_variables.py` — deadlines, flags, reimbursement limits, touched every year +- Where templates live (per-app `templates/` folders) +- Where styles live (per-app `static/css/` + global `app/static/css/`) +- URL-to-app mapping table + +### `docs/setting-up.md` +- Prerequisites, virtualenv, dependencies +- Environment variables and `hackathon_variables.py` setup +- Running the dev server, creating a superuser +- How to verify your changes work +*(Content extracted from README; README updated to point here)* + +### `docs/contributing.md` +Worked examples covering the most common yearly changes: +- **Adding/removing a field from the hacker application** — which files to touch: model (`applications/models/hacker.py`), form (`applications/forms/hacker.py`), migration, template +- **Adding/removing a field from volunteer or mentor application** — same pattern, different files +- **Changing the travel reimbursement cap** — just `app/hackathon_variables.py` + +### `docs/email-templates.md` +- Table of every email the system sends: email name, which app sends it, template path +- How to edit email content: edit `*_message.html` +- How to edit the subject line: edit `*_subject.txt` +- How to add a new variable to an email: add it to the context dict in the app's `emails.py`, then use `{{ variable_name }}` in the template + +--- + +## Safety Constraints + +1. No logic changes — only move code between files, never rewrite it +2. `views/__init__.py` must re-export every name imported from that views module by **any** file in the project — not just `urls.py`. Verify with a project-wide grep after each split. +3. CSS cascade order preserved — `bootstrap-overrides/` files loaded in `base.html` in the same order as sections appeared in the original file +4. No new dependencies introduced +5. All migrations left untouched +6. Excluded apps (`judging`, `offer`, `discord`) are not modified + +--- + +## Execution Order + +1. Documentation (`docs/` folder) — zero risk, pure addition +2. CSS reorganization — no logic, visual regression check +3. Views splitting — one app at a time, verify after each: + - Run the dev server and confirm it starts cleanly + - For `applications`: also run `python manage.py expire_applications --help` to confirm the management command still imports cleanly (Django dev server does not import management commands on startup, so a broken command will not be caught by a server start check alone) + - Do a project-wide grep for imports of the converted module to confirm nothing was missed + +--- + +## Out of Scope + +- Upgrading Django (currently 3.2 LTS) +- Rewriting any view, model, or form logic +- Adding new features +- Changing URL structure +- Improving test coverage diff --git a/hardware/views/__init__.py b/hardware/views/__init__.py new file mode 100644 index 000000000..70677ffb5 --- /dev/null +++ b/hardware/views/__init__.py @@ -0,0 +1,7 @@ +from hardware.views.hacker import HardwareBorrowingsView, HardwareListView +from hardware.views.admin import hardware_tabs, HardwareAdminRequestsView, HardwareAdminView + +__all__ = [ + 'HardwareBorrowingsView', 'HardwareListView', + 'hardware_tabs', 'HardwareAdminRequestsView', 'HardwareAdminView', +] diff --git a/hardware/views.py b/hardware/views/admin.py similarity index 74% rename from hardware/views.py rename to hardware/views/admin.py index 1c5b63fea..5cccaa536 100644 --- a/hardware/views.py +++ b/hardware/views/admin.py @@ -1,4 +1,3 @@ -from app import hackathon_variables from app.mixins import TabsViewMixin from django.core import serializers from django.http import JsonResponse, HttpResponse @@ -9,12 +8,12 @@ from django_filters.views import FilterView from django_tables2 import SingleTableMixin from django.db.models import Q -from user.mixins import IsHardwareAdminMixin, IsHackerMixin +from user.mixins import IsHardwareAdminMixin from user.models import User from checkin.models import CheckIn from hardware.models import Item, ItemType, Borrowing, Request -from hardware.tables import BorrowingTable, BorrowingFilter, RequestTable, RequestFilter +from hardware.tables import RequestTable, RequestFilter def hardware_tabs(user): @@ -45,79 +44,6 @@ def get_queryset(self): return Request.objects.all() -class HardwareBorrowingsView(IsHackerMixin, TabsViewMixin, SingleTableMixin, FilterView): - template_name = 'hardware_borrowings.html' - table_class = BorrowingTable - table_pagination = {'per_page': 50} - filterset_class = BorrowingFilter - - def get_context_data(self, **kwargs): - context = super(HardwareBorrowingsView, self).get_context_data(**kwargs) - if not self.request.user.is_hardware_admin: - context['filter'] = False - context['table'].exclude = ('id', 'user', 'lending_by', 'return_by') - - return context - - def get_current_tabs(self): - return hardware_tabs(self.request.user) - - def get_queryset(self): - if self.request.user.is_hardware_admin: - return Borrowing.objects.all() - else: - return Borrowing.objects.get_queryset().filter(user=self.request.user) - - -class HardwareListView(IsHackerMixin, TabsViewMixin, TemplateView): - template_name = 'hardware_list.html' - - def get_current_tabs(self): - return hardware_tabs(self.request.user) - - def get_context_data(self, **kwargs): - context = super(HardwareListView, self).get_context_data(**kwargs) - context['hw_list'] = ItemType.objects.all() - requests = Request.objects.get_active_by_user(self.request.user) - context['requests'] = { - x.item_type.id: x.get_remaining_time() for x in requests - } - return context - - def req_item(self, request): - item = ItemType.objects.get(id=request.POST['item_id']) - if item.get_available_count() > 0: - item.make_request(request.user) - return JsonResponse({ - 'ok': True, - 'minutes': hackathon_variables.HARDWARE_REQUEST_TIME - }) - - return JsonResponse({'msg': "ERROR: There are no items available"}) - - def check_availability(self, request): - item_ids = request.POST['item_ids[]'] - items = ItemType.objects.filter(id__in=item_ids) - available_items = [] - for item in items: - if item.get_available_count() > 0: - available_items.append({ - "id": item.id, - "name": item.name - }) - - return JsonResponse({ - 'available_items': available_items - }) - - def post(self, request): - if request.is_ajax: - if 'req_item' in request.POST: - return self.req_item(request) - if 'check_availability' in request.POST: - return self.check_availability(request) - - class HardwareAdminView(IsHardwareAdminMixin, TabsViewMixin, TemplateView): template_name = 'hardware_admin.html' diff --git a/hardware/views/hacker.py b/hardware/views/hacker.py new file mode 100644 index 000000000..78e8cb4c1 --- /dev/null +++ b/hardware/views/hacker.py @@ -0,0 +1,84 @@ +from app import hackathon_variables +from app.mixins import TabsViewMixin +from django.http import JsonResponse +from django.views.generic import TemplateView +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin +from user.mixins import IsHackerMixin + +from hardware.models import ItemType, Borrowing, Request +from hardware.tables import BorrowingTable, BorrowingFilter +from hardware.views.admin import hardware_tabs + + +class HardwareBorrowingsView(IsHackerMixin, TabsViewMixin, SingleTableMixin, FilterView): + template_name = 'hardware_borrowings.html' + table_class = BorrowingTable + table_pagination = {'per_page': 50} + filterset_class = BorrowingFilter + + def get_context_data(self, **kwargs): + context = super(HardwareBorrowingsView, self).get_context_data(**kwargs) + if not self.request.user.is_hardware_admin: + context['filter'] = False + context['table'].exclude = ('id', 'user', 'lending_by', 'return_by') + + return context + + def get_current_tabs(self): + return hardware_tabs(self.request.user) + + def get_queryset(self): + if self.request.user.is_hardware_admin: + return Borrowing.objects.all() + else: + return Borrowing.objects.get_queryset().filter(user=self.request.user) + + +class HardwareListView(IsHackerMixin, TabsViewMixin, TemplateView): + template_name = 'hardware_list.html' + + def get_current_tabs(self): + return hardware_tabs(self.request.user) + + def get_context_data(self, **kwargs): + context = super(HardwareListView, self).get_context_data(**kwargs) + context['hw_list'] = ItemType.objects.all() + requests = Request.objects.get_active_by_user(self.request.user) + context['requests'] = { + x.item_type.id: x.get_remaining_time() for x in requests + } + return context + + def req_item(self, request): + item = ItemType.objects.get(id=request.POST['item_id']) + if item.get_available_count() > 0: + item.make_request(request.user) + return JsonResponse({ + 'ok': True, + 'minutes': hackathon_variables.HARDWARE_REQUEST_TIME + }) + + return JsonResponse({'msg': "ERROR: There are no items available"}) + + def check_availability(self, request): + item_ids = request.POST['item_ids[]'] + items = ItemType.objects.filter(id__in=item_ids) + available_items = [] + for item in items: + if item.get_available_count() > 0: + available_items.append({ + "id": item.id, + "name": item.name + }) + + return JsonResponse({ + 'available_items': available_items + }) + + def post(self, request): + if request.is_ajax: + if 'req_item' in request.POST: + return self.req_item(request) + if 'check_availability' in request.POST: + return self.check_availability(request) diff --git a/organizers/static/css/organizers.css b/organizers/static/css/organizers.css new file mode 100644 index 000000000..38615fa52 --- /dev/null +++ b/organizers/static/css/organizers.css @@ -0,0 +1,18 @@ +.container-iframe { + position: relative; + overflow: hidden; + width: 100%; + padding-top: 56.25%; /* 16:9 Aspect Ratio (divide 9 by 16 = 0.5625) */ +} + +.responsive-iframe { + display: block; + border-style:none; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + width: 100%; + height: 100%; +} diff --git a/organizers/templates/review_resume.html b/organizers/templates/review_resume.html index 136b953cf..5eeebada0 100644 --- a/organizers/templates/review_resume.html +++ b/organizers/templates/review_resume.html @@ -1,5 +1,6 @@ {% extends "base_tabs.html" %} {% load static %} +{% block extra_head %}{% endblock %} {% block head_title %}Review resume{% endblock %} {% block panel %} {% if app %} diff --git a/organizers/views/__init__.py b/organizers/views/__init__.py new file mode 100644 index 000000000..c1234e2b7 --- /dev/null +++ b/organizers/views/__init__.py @@ -0,0 +1,42 @@ +from organizers.views.lists import ( + hacker_tabs, + volunteer_tabs, + mentor_tabs, + sponsor_tabs, + ApplicationsListView, + InviteListView, + WaitlistedApplicationsListView, + DubiousApplicationsListView, + BlacklistApplicationsListView, + _OtherApplicationsListView, + VolunteerApplicationsListView, + SponsorApplicationsListView, + SponsorUserListView, + MentorApplicationsListView, +) +from organizers.views.review import ( + add_vote, + add_comment, + ApplicationDetailView, + ReviewApplicationView, + ReviewApplicationDetailView, + ReviewVolunteerApplicationView, + ReviewSponsorApplicationView, + ReviewMentorApplicationView, + ReviewResume, + VisualizeResume, +) +from organizers.views.batch_ops import InviteTeamListView + +__all__ = [ + 'hacker_tabs', 'volunteer_tabs', 'mentor_tabs', 'sponsor_tabs', + 'ApplicationsListView', 'InviteListView', 'WaitlistedApplicationsListView', + 'DubiousApplicationsListView', 'BlacklistApplicationsListView', + '_OtherApplicationsListView', 'VolunteerApplicationsListView', + 'SponsorApplicationsListView', 'SponsorUserListView', 'MentorApplicationsListView', + 'add_vote', 'add_comment', + 'ApplicationDetailView', 'ReviewApplicationView', 'ReviewApplicationDetailView', + 'ReviewVolunteerApplicationView', 'ReviewSponsorApplicationView', + 'ReviewMentorApplicationView', 'ReviewResume', 'VisualizeResume', + 'InviteTeamListView', +] diff --git a/organizers/views/batch_ops.py b/organizers/views/batch_ops.py new file mode 100644 index 000000000..55e03d75b --- /dev/null +++ b/organizers/views/batch_ops.py @@ -0,0 +1,140 @@ +# Create your views here. +from django.conf import settings +from django.contrib import messages +from django.core.exceptions import ValidationError +from django.db.models import Count, Avg, F, Q, CharField +from django.db.models.functions import Concat +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.views.generic import TemplateView +from django_tables2 import SingleTableMixin +from django.utils import timezone + +from app.mixins import TabsViewMixin +from applications import emails +from applications.emails import send_batch_emails +from applications.models import ( + APP_PENDING, + APP_DUBIOUS, + APP_BLACKLISTED, + APP_INVITED, + APP_LAST_REMIDER, + APP_CONFIRMED, + APP_REJECTED, +) +from organizers import models +from organizers.tables import AdminTeamListTable +from user.mixins import IsDirectorMixin + +from organizers.views.lists import hacker_tabs + + +class InviteTeamListView( + TabsViewMixin, IsDirectorMixin, SingleTableMixin, TemplateView +): + template_name = "invite_list.html" + table_class = AdminTeamListTable + table_pagination = {"per_page": 100} + + def get_current_tabs(self): + return hacker_tabs(self.request.user) + + def get_queryset(self): + hackersList = ( + models.HackerApplication.objects.filter( + status__in=[ + APP_PENDING, + APP_CONFIRMED, + APP_LAST_REMIDER, + APP_INVITED, + APP_REJECTED, + ] + ) + .exclude(user__team__team_code__isnull=True) + .values("user__team__team_code") + .annotate( + vote_avg=Avg("vote__calculated_vote"), + members=Count("user", distinct=True), + invited=Count( + Concat("status", "user__id", output_field=CharField()), + filter=Q(status__in=[APP_INVITED, APP_LAST_REMIDER]), + distinct=True, + ), + accepted=Count( + Concat("status", "user__id", output_field=CharField()), + filter=Q(status=APP_CONFIRMED), + distinct=True, + ), + live_pending=Count( + Concat("status", "user__id", output_field=CharField()), + filter=Q(status__in=[APP_PENDING, APP_REJECTED], online=False), + distinct=True, + ), + ) + .exclude(members=F("accepted")) + .exclude(Q(live_pending=0) | Q(live_pending__gt=F("members") / 2)) + .order_by("-vote_avg") + ) + + return hackersList + + def get_context_data(self, **kwargs): + context = super(InviteTeamListView, self).get_context_data(**kwargs) + context.update({"teams": True}) + + n_live_hackers = models.HackerApplication.objects.filter( + status__in=[APP_INVITED, APP_LAST_REMIDER, APP_CONFIRMED], online=False + ).count() + + n_invited_hackers_today = models.HackerApplication.objects.filter( + status__in=[APP_INVITED], + online=False, + status_update_date__date=timezone.now().date(), + ).count() + + n_waitlisted_hackers = models.HackerApplication.objects.filter( + status__in=[APP_REJECTED], online=False + ).count() + + context.update( + { + "n_live_hackers": n_live_hackers, + "n_live_per_hackers": n_live_hackers + * 100 + / getattr(settings, "N_MAX_LIVE_HACKERS", 0), + "n_invited_hackers_today": n_invited_hackers_today, + "n_waitlisted_hackers": n_waitlisted_hackers, + } + ) + return context + + def post(self, request, *args, **kwargs): + ids = request.POST.getlist("selected") + apps = ( + models.HackerApplication.objects.filter(user__team__team_code__in=ids) + .exclude(status__in=[APP_DUBIOUS, APP_BLACKLISTED]) + .annotate(count=Count("vote")) + .filter(count__gte=5) + ) + mails = [] + errors = 0 + for app in apps: + try: + app.invite( + request.user, + online=request.POST.get("force_online", "false") == "true", + ) + m = emails.create_invite_email(app, request) + mails.append(m) + except ValidationError: + errors += 1 + if mails: + send_batch_emails(mails) + messages.success(request, "%s applications invited" % len(mails)) + else: + errorMsg = "No applications invited" + if errors != 0: + errorMsg = "%s applications not invited" % errors + messages.error(request, errorMsg) + + return HttpResponseRedirect(reverse("invite_teams_list")) diff --git a/organizers/views/lists.py b/organizers/views/lists.py new file mode 100644 index 000000000..563636b7c --- /dev/null +++ b/organizers/views/lists.py @@ -0,0 +1,401 @@ +# Create your views here. +import os +from io import BytesIO +from zipfile import ZipFile + +from django.conf import settings +from django.contrib import messages +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.db.models import Count, Avg, F, Q, CharField +from django.db.models.functions import Concat +from django.http import Http404, HttpResponseRedirect, HttpResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.views import View +from django.views.generic import TemplateView +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin +from django_tables2.export import ExportMixin +from django.utils import timezone +from datetime import timedelta + +from app import slack +from app.mixins import TabsViewMixin +from app.slack import SlackInvitationException +from applications import emails +from applications.emails import send_batch_emails +from applications.models import ( + APP_PENDING, + APP_DUBIOUS, + APP_BLACKLISTED, + APP_INVITED, + APP_LAST_REMIDER, + APP_CONFIRMED, + AcceptedResume, + APP_ATTENDED, + APP_REJECTED, +) +from organizers import models +from organizers.tables import ( + ApplicationsListTable, + ApplicationFilter, + AdminApplicationsListTable, + AdminTeamListTable, + InviteFilter, + DubiousListTable, + DubiousApplicationFilter, + VolunteerFilter, + VolunteerListTable, + MentorListTable, + MentorFilter, + SponsorListTable, + SponsorFilter, + SponsorUserListTable, + SponsorUserFilter, + BlacklistListTable, + BlacklistApplicationFilter, +) +from teams.models import Team +from user.mixins import ( + IsOrganizerMixin, + IsDirectorMixin, + HaveDubiousPermissionMixin, + HaveSponsorPermissionMixin, + HaveMentorPermissionMixin, + IsBlacklistAdminMixin, +) +from user.models import User, USR_SPONSOR + +if getattr(settings, "REIMBURSEMENT_ENABLED", False): + from reimbursement.models import Reimbursement, RE_PEND_APPROVAL + + +def hacker_tabs(user): + new_app = models.HackerApplication.objects.exclude(vote__user_id=user.id).filter( + status=APP_PENDING, submission_date__lte=timezone.now() - timedelta(hours=2) + ) + t = [ + ("Application", reverse("app_list"), False), + ("Review", reverse("review"), "new" if new_app else ""), + ] + if user.has_dubious_access and getattr(settings, "DUBIOUS_ENABLED", False): + t.append( + ( + "Dubious", + reverse("dubious"), + ( + "new" + if models.HackerApplication.objects.filter( + status=APP_DUBIOUS, contacted=False + ).count() + else "" + ), + ) + ) + if user.has_blacklist_access and getattr(settings, "BLACKLIST_ENABLED", False): + t.append( + ( + "Blacklist", + reverse("blacklist"), + ( + "new" + if models.HackerApplication.objects.filter( + status=APP_BLACKLISTED, contacted=False + ).count() + else "" + ), + ) + ) + t.append(("Check-in", reverse("check_in_list"), False)) + if user.has_reimbursement_access: + t.extend( + [ + ("Reimbursements", reverse("reimbursement_list"), False), + ( + "Receipts", + reverse("receipt_review"), + ( + "new" + if Reimbursement.objects.filter(status=RE_PEND_APPROVAL).count() + else False + ), + ), + ] + ) + if user.has_sponsor_access: + new_resume = ( + models.HackerApplication.objects.filter( + acceptedresume__isnull=True, cvs_edition=True + ) + .exclude(status__in=[APP_DUBIOUS, APP_BLACKLISTED]) + .first() + ) + t.append( + ("Review resume", reverse("review_resume"), "new" if new_resume else "") + ) + return t + + +def sponsor_tabs(user): + return [ + ("Users", reverse("sponsor_user_list"), False), + ("Application", reverse("sponsor_list"), False), + ("Check-in", reverse("check_in_sponsor_list"), False), + ] + + +def volunteer_tabs(user): + return [ + ("Application", reverse("volunteer_list"), False), + ("Check-in", reverse("check_in_volunteer_list"), False), + ] + + +def mentor_tabs(user): + return [ + ("Application", reverse("mentor_list"), False), + ("Check-in", reverse("check_in_mentor_list"), False), + ] + + +class ApplicationsListView( + TabsViewMixin, IsOrganizerMixin, ExportMixin, SingleTableMixin, FilterView +): + template_name = "applications_list.html" + table_class = ApplicationsListTable + filterset_class = ApplicationFilter + table_pagination = {"per_page": 100} + exclude_columns = ("detail", "status", "vote_avg") + export_name = "applications" + + def get(self, request, *args, **kwargs): + request.session["edit_app_back"] = "app_list" + return super().get(request, *args, **kwargs) + + def get_current_tabs(self): + return hacker_tabs(self.request.user) + + def get_queryset(self): + return models.HackerApplication.annotate_vote( + models.HackerApplication.objects.all() + ) + + def get_context_data(self, **kwargs): + context = super(ApplicationsListView, self).get_context_data(**kwargs) + context["otherApplication"] = False + list_email = "" + for u in context.get("object_list").values_list("user__email", flat=True): + list_email += "%s, " % u + context["emails"] = list_email + return context + + +class InviteListView(TabsViewMixin, IsDirectorMixin, SingleTableMixin, FilterView): + template_name = "invite_list.html" + table_class = AdminApplicationsListTable + filterset_class = InviteFilter + table_pagination = {"per_page": 100} + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + n_invited_hackers_today = models.HackerApplication.objects.filter( + status=APP_INVITED, + online=False, + status_update_date__date=timezone.now().date(), + ).count() + + n_waitlisted_hackers = models.HackerApplication.objects.filter( + status=APP_REJECTED, online=False + ).count() + n_live_hackers = models.HackerApplication.objects.filter( + status__in=[APP_INVITED, APP_LAST_REMIDER, APP_CONFIRMED], online=False + ).count() + + context.update( + { + "n_live_hackers": n_live_hackers, + "n_live_per_hackers": n_live_hackers + * 100 + / getattr(settings, "N_MAX_LIVE_HACKERS", 0), + "n_waitlisted_hackers": n_waitlisted_hackers, + "n_invited_hackers_today": n_invited_hackers_today, + } + ) + + return context + + def get_current_tabs(self): + return hacker_tabs(self.request.user) + + def get_queryset(self): + return models.HackerApplication.annotate_vote( + models.HackerApplication.objects.filter( + status__in=[APP_PENDING, APP_REJECTED] + ) + ).order_by("-vote_avg") + + def post(self, request, *args, **kwargs): + ids = request.POST.getlist("selected") + apps = models.HackerApplication.objects.filter(pk__in=ids).all() + mails = [] + errors = 0 + for app in apps: + try: + app.invite( + request.user, + online=request.POST.get("force_online", "false") == "true", + ) + m = emails.create_invite_email(app, request) + if m: + mails.append(m) + except ValidationError: + errors += 1 + if mails: + send_batch_emails(mails) + messages.success(request, "%s applications invited" % len(mails)) + else: + errorMsg = "No applications invited" + if errors != 0: + errorMsg = "%s applications not invited" % errors + messages.error(request, errorMsg) + + return HttpResponseRedirect(reverse("invite_list")) + + +class WaitlistedApplicationsListView( + IsDirectorMixin, ExportMixin, SingleTableMixin, View +): + # This view is to send all hacker applications left under_review to waitlisted + def post(self, request, *args, **kwargs): + models.HackerApplication.objects.filter(status=APP_PENDING).update( + status=APP_REJECTED + ) + return HttpResponse(status=200) + + +class DubiousApplicationsListView( + TabsViewMixin, HaveDubiousPermissionMixin, ExportMixin, SingleTableMixin, FilterView +): + template_name = "dubious_list.html" + table_class = DubiousListTable + filterset_class = DubiousApplicationFilter + table_pagination = {"per_page": 100} + exclude_columns = ("status", "vote_avg") + export_name = "dubious_applications" + + def get(self, request, *args, **kwargs): + request.session["edit_app_back"] = "dubious" + return super().get(request, *args, **kwargs) + + def get_current_tabs(self): + return hacker_tabs(self.request.user) + + def get_queryset(self): + return models.HackerApplication.objects.filter(status=APP_DUBIOUS).order_by( + "-status_update_date" + ) + + +class BlacklistApplicationsListView( + TabsViewMixin, IsBlacklistAdminMixin, ExportMixin, SingleTableMixin, FilterView +): + template_name = "blacklist_list.html" + table_class = BlacklistListTable + filterset_class = BlacklistApplicationFilter + table_pagination = {"per_page": 100} + exclude_columns = ("status", "vote_avg") + export_name = "blacklist_applications" + + def get(self, request, *args, **kwargs): + request.session["edit_app_back"] = "blacklist" + return super().get(request, *args, **kwargs) + + def get_current_tabs(self): + return hacker_tabs(self.request.user) + + def get_queryset(self): + return models.HackerApplication.objects.filter(status=APP_BLACKLISTED) + + +class _OtherApplicationsListView( + TabsViewMixin, ExportMixin, SingleTableMixin, FilterView +): + template_name = "applications_list.html" + table_pagination = {"per_page": 100} + exclude_columns = ("detail", "status") + export_name = "applications" + email_field = "user__email" + + def get_context_data(self, **kwargs): + context = super(_OtherApplicationsListView, self).get_context_data(**kwargs) + context["otherApplication"] = True + list_email = "" + for u in context.get("object_list").values_list(self.email_field, flat=True): + list_email += "%s, " % u + context["emails"] = list_email + return context + + +class VolunteerApplicationsListView(IsOrganizerMixin, _OtherApplicationsListView): + table_class = VolunteerListTable + filterset_class = VolunteerFilter + + def get_queryset(self): + return models.VolunteerApplication.objects.all() + + def get_current_tabs(self): + return volunteer_tabs(self.request.user) + + +class SponsorApplicationsListView( + HaveSponsorPermissionMixin, _OtherApplicationsListView +): + table_class = SponsorListTable + filterset_class = SponsorFilter + email_field = "email" + + def get_queryset(self): + return models.SponsorApplication.objects.all() + + def get_context_data(self, **kwargs): + context = super(SponsorApplicationsListView, self).get_context_data(**kwargs) + context["otherApplication"] = True + return context + + def get_current_tabs(self): + return sponsor_tabs(self.request.user) + + +class SponsorUserListView( + HaveSponsorPermissionMixin, TabsViewMixin, ExportMixin, SingleTableMixin, FilterView +): + template_name = "applications_list.html" + table_pagination = {"per_page": 100} + exclude_columns = ("detail", "status") + export_name = "applications" + table_class = SponsorUserListTable + filterset_class = SponsorUserFilter + + def get_current_tabs(self): + return sponsor_tabs(self.request.user) + + def get_context_data(self, **kwargs): + context = super(SponsorUserListView, self).get_context_data(**kwargs) + context["otherApplication"] = True + context["createUser"] = True + return context + + def get_queryset(self): + return User.objects.filter(type=USR_SPONSOR).exclude(max_applications=0) + + +class MentorApplicationsListView(HaveMentorPermissionMixin, _OtherApplicationsListView): + table_class = MentorListTable + filterset_class = MentorFilter + + def get_queryset(self): + return models.MentorApplication.objects.all() + + def get_current_tabs(self): + return mentor_tabs(self.request.user) diff --git a/organizers/views.py b/organizers/views/review.py similarity index 61% rename from organizers/views.py rename to organizers/views/review.py index 84e4ff239..c5b7a68cc 100644 --- a/organizers/views.py +++ b/organizers/views/review.py @@ -7,16 +7,11 @@ from django.contrib import messages from django.core.exceptions import ValidationError from django.db import IntegrityError -from django.db.models import Count, Avg, F, Q, CharField -from django.db.models.functions import Concat +from django.db.models import Count, Q from django.http import Http404, HttpResponseRedirect, HttpResponse from django.shortcuts import redirect from django.urls import reverse -from django.views import View from django.views.generic import TemplateView -from django_filters.views import FilterView -from django_tables2 import SingleTableMixin -from django_tables2.export import ExportMixin from django.utils import timezone from datetime import timedelta @@ -24,51 +19,23 @@ from app.mixins import TabsViewMixin from app.slack import SlackInvitationException from applications import emails -from applications.emails import send_batch_emails from applications.models import ( APP_PENDING, APP_DUBIOUS, APP_BLACKLISTED, - APP_INVITED, - APP_LAST_REMIDER, APP_CONFIRMED, AcceptedResume, APP_ATTENDED, - APP_REJECTED, ) from organizers import models -from organizers.tables import ( - ApplicationsListTable, - ApplicationFilter, - AdminApplicationsListTable, - AdminTeamListTable, - InviteFilter, - DubiousListTable, - DubiousApplicationFilter, - VolunteerFilter, - VolunteerListTable, - MentorListTable, - MentorFilter, - SponsorListTable, - SponsorFilter, - SponsorUserListTable, - SponsorUserFilter, - BlacklistListTable, - BlacklistApplicationFilter, -) from teams.models import Team from user.mixins import ( IsOrganizerMixin, - IsDirectorMixin, - HaveDubiousPermissionMixin, HaveSponsorPermissionMixin, HaveMentorPermissionMixin, - IsBlacklistAdminMixin, ) -from user.models import User, USR_SPONSOR -if getattr(settings, "REIMBURSEMENT_ENABLED", False): - from reimbursement.models import Reimbursement, RE_PEND_APPROVAL +from organizers.views.lists import hacker_tabs def add_vote(application, user, tech_rat, pers_rat): @@ -99,198 +66,6 @@ def add_comment(application, user, text): return comment -def hacker_tabs(user): - new_app = models.HackerApplication.objects.exclude(vote__user_id=user.id).filter( - status=APP_PENDING, submission_date__lte=timezone.now() - timedelta(hours=2) - ) - t = [ - ("Application", reverse("app_list"), False), - ("Review", reverse("review"), "new" if new_app else ""), - ] - if user.has_dubious_access and getattr(settings, "DUBIOUS_ENABLED", False): - t.append( - ( - "Dubious", - reverse("dubious"), - ( - "new" - if models.HackerApplication.objects.filter( - status=APP_DUBIOUS, contacted=False - ).count() - else "" - ), - ) - ) - if user.has_blacklist_access and getattr(settings, "BLACKLIST_ENABLED", False): - t.append( - ( - "Blacklist", - reverse("blacklist"), - ( - "new" - if models.HackerApplication.objects.filter( - status=APP_BLACKLISTED, contacted=False - ).count() - else "" - ), - ) - ) - t.append(("Check-in", reverse("check_in_list"), False)) - if user.has_reimbursement_access: - t.extend( - [ - ("Reimbursements", reverse("reimbursement_list"), False), - ( - "Receipts", - reverse("receipt_review"), - ( - "new" - if Reimbursement.objects.filter(status=RE_PEND_APPROVAL).count() - else False - ), - ), - ] - ) - if user.has_sponsor_access: - new_resume = ( - models.HackerApplication.objects.filter( - acceptedresume__isnull=True, cvs_edition=True - ) - .exclude(status__in=[APP_DUBIOUS, APP_BLACKLISTED]) - .first() - ) - t.append( - ("Review resume", reverse("review_resume"), "new" if new_resume else "") - ) - return t - - -def sponsor_tabs(user): - return [ - ("Users", reverse("sponsor_user_list"), False), - ("Application", reverse("sponsor_list"), False), - ("Check-in", reverse("check_in_sponsor_list"), False), - ] - - -def volunteer_tabs(user): - return [ - ("Application", reverse("volunteer_list"), False), - ("Check-in", reverse("check_in_volunteer_list"), False), - ] - - -def mentor_tabs(user): - return [ - ("Application", reverse("mentor_list"), False), - ("Check-in", reverse("check_in_mentor_list"), False), - ] - - -class ApplicationsListView( - TabsViewMixin, IsOrganizerMixin, ExportMixin, SingleTableMixin, FilterView -): - template_name = "applications_list.html" - table_class = ApplicationsListTable - filterset_class = ApplicationFilter - table_pagination = {"per_page": 100} - exclude_columns = ("detail", "status", "vote_avg") - export_name = "applications" - - def get(self, request, *args, **kwargs): - request.session["edit_app_back"] = "app_list" - return super().get(request, *args, **kwargs) - - def get_current_tabs(self): - return hacker_tabs(self.request.user) - - def get_queryset(self): - return models.HackerApplication.annotate_vote( - models.HackerApplication.objects.all() - ) - - def get_context_data(self, **kwargs): - context = super(ApplicationsListView, self).get_context_data(**kwargs) - context["otherApplication"] = False - list_email = "" - for u in context.get("object_list").values_list("user__email", flat=True): - list_email += "%s, " % u - context["emails"] = list_email - return context - - -class InviteListView(TabsViewMixin, IsDirectorMixin, SingleTableMixin, FilterView): - template_name = "invite_list.html" - table_class = AdminApplicationsListTable - filterset_class = InviteFilter - table_pagination = {"per_page": 100} - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - n_invited_hackers_today = models.HackerApplication.objects.filter( - status=APP_INVITED, - online=False, - status_update_date__date=timezone.now().date(), - ).count() - - n_waitlisted_hackers = models.HackerApplication.objects.filter( - status=APP_REJECTED, online=False - ).count() - n_live_hackers = models.HackerApplication.objects.filter( - status__in=[APP_INVITED, APP_LAST_REMIDER, APP_CONFIRMED], online=False - ).count() - - context.update( - { - "n_live_hackers": n_live_hackers, - "n_live_per_hackers": n_live_hackers - * 100 - / getattr(settings, "N_MAX_LIVE_HACKERS", 0), - "n_waitlisted_hackers": n_waitlisted_hackers, - "n_invited_hackers_today": n_invited_hackers_today, - } - ) - - return context - - def get_current_tabs(self): - return hacker_tabs(self.request.user) - - def get_queryset(self): - return models.HackerApplication.annotate_vote( - models.HackerApplication.objects.filter( - status__in=[APP_PENDING, APP_REJECTED] - ) - ).order_by("-vote_avg") - - def post(self, request, *args, **kwargs): - ids = request.POST.getlist("selected") - apps = models.HackerApplication.objects.filter(pk__in=ids).all() - mails = [] - errors = 0 - for app in apps: - try: - app.invite( - request.user, - online=request.POST.get("force_online", "false") == "true", - ) - m = emails.create_invite_email(app, request) - if m: - mails.append(m) - except ValidationError: - errors += 1 - if mails: - send_batch_emails(mails) - messages.success(request, "%s applications invited" % len(mails)) - else: - errorMsg = "No applications invited" - if errors != 0: - errorMsg = "%s applications not invited" % errors - messages.error(request, errorMsg) - - return HttpResponseRedirect(reverse("invite_list")) - - class ApplicationDetailView(TabsViewMixin, IsOrganizerMixin, TemplateView): template_name = "application_detail.html" @@ -666,255 +441,6 @@ def can_vote(self): return True -class InviteTeamListView( - TabsViewMixin, IsDirectorMixin, SingleTableMixin, TemplateView -): - template_name = "invite_list.html" - table_class = AdminTeamListTable - table_pagination = {"per_page": 100} - - def get_current_tabs(self): - return hacker_tabs(self.request.user) - - def get_queryset(self): - hackersList = ( - models.HackerApplication.objects.filter( - status__in=[ - APP_PENDING, - APP_CONFIRMED, - APP_LAST_REMIDER, - APP_INVITED, - APP_REJECTED, - ] - ) - .exclude(user__team__team_code__isnull=True) - .values("user__team__team_code") - .annotate( - vote_avg=Avg("vote__calculated_vote"), - members=Count("user", distinct=True), - invited=Count( - Concat("status", "user__id", output_field=CharField()), - filter=Q(status__in=[APP_INVITED, APP_LAST_REMIDER]), - distinct=True, - ), - accepted=Count( - Concat("status", "user__id", output_field=CharField()), - filter=Q(status=APP_CONFIRMED), - distinct=True, - ), - live_pending=Count( - Concat("status", "user__id", output_field=CharField()), - filter=Q(status__in=[APP_PENDING, APP_REJECTED], online=False), - distinct=True, - ), - ) - .exclude(members=F("accepted")) - .exclude(Q(live_pending=0) | Q(live_pending__gt=F("members") / 2)) - .order_by("-vote_avg") - ) - - return hackersList - - def get_context_data(self, **kwargs): - context = super(InviteTeamListView, self).get_context_data(**kwargs) - context.update({"teams": True}) - - n_live_hackers = models.HackerApplication.objects.filter( - status__in=[APP_INVITED, APP_LAST_REMIDER, APP_CONFIRMED], online=False - ).count() - - n_invited_hackers_today = models.HackerApplication.objects.filter( - status__in=[APP_INVITED], - online=False, - status_update_date__date=timezone.now().date(), - ).count() - - n_waitlisted_hackers = models.HackerApplication.objects.filter( - status__in=[APP_REJECTED], online=False - ).count() - - context.update( - { - "n_live_hackers": n_live_hackers, - "n_live_per_hackers": n_live_hackers - * 100 - / getattr(settings, "N_MAX_LIVE_HACKERS", 0), - "n_invited_hackers_today": n_invited_hackers_today, - "n_waitlisted_hackers": n_waitlisted_hackers, - } - ) - return context - - def post(self, request, *args, **kwargs): - ids = request.POST.getlist("selected") - apps = ( - models.HackerApplication.objects.filter(user__team__team_code__in=ids) - .exclude(status__in=[APP_DUBIOUS, APP_BLACKLISTED]) - .annotate(count=Count("vote")) - .filter(count__gte=5) - ) - mails = [] - errors = 0 - for app in apps: - try: - app.invite( - request.user, - online=request.POST.get("force_online", "false") == "true", - ) - m = emails.create_invite_email(app, request) - mails.append(m) - except ValidationError: - errors += 1 - if mails: - send_batch_emails(mails) - messages.success(request, "%s applications invited" % len(mails)) - else: - errorMsg = "No applications invited" - if errors != 0: - errorMsg = "%s applications not invited" % errors - messages.error(request, errorMsg) - - return HttpResponseRedirect(reverse("invite_teams_list")) - - -class WaitlistedApplicationsListView( - IsDirectorMixin, ExportMixin, SingleTableMixin, View -): - # This view is to send all hacker applications left under_review to waitlisted - def post(self, request, *args, **kwargs): - models.HackerApplication.objects.filter(status=APP_PENDING).update( - status=APP_REJECTED - ) - return HttpResponse(status=200) - - -class DubiousApplicationsListView( - TabsViewMixin, HaveDubiousPermissionMixin, ExportMixin, SingleTableMixin, FilterView -): - template_name = "dubious_list.html" - table_class = DubiousListTable - filterset_class = DubiousApplicationFilter - table_pagination = {"per_page": 100} - exclude_columns = ("status", "vote_avg") - export_name = "dubious_applications" - - def get(self, request, *args, **kwargs): - request.session["edit_app_back"] = "dubious" - return super().get(request, *args, **kwargs) - - def get_current_tabs(self): - return hacker_tabs(self.request.user) - - def get_queryset(self): - return models.HackerApplication.objects.filter(status=APP_DUBIOUS).order_by( - "-status_update_date" - ) - - -class BlacklistApplicationsListView( - TabsViewMixin, IsBlacklistAdminMixin, ExportMixin, SingleTableMixin, FilterView -): - template_name = "blacklist_list.html" - table_class = BlacklistListTable - filterset_class = BlacklistApplicationFilter - table_pagination = {"per_page": 100} - exclude_columns = ("status", "vote_avg") - export_name = "blacklist_applications" - - def get(self, request, *args, **kwargs): - request.session["edit_app_back"] = "blacklist" - return super().get(request, *args, **kwargs) - - def get_current_tabs(self): - return hacker_tabs(self.request.user) - - def get_queryset(self): - return models.HackerApplication.objects.filter(status=APP_BLACKLISTED) - - -class _OtherApplicationsListView( - TabsViewMixin, ExportMixin, SingleTableMixin, FilterView -): - template_name = "applications_list.html" - table_pagination = {"per_page": 100} - exclude_columns = ("detail", "status") - export_name = "applications" - email_field = "user__email" - - def get_context_data(self, **kwargs): - context = super(_OtherApplicationsListView, self).get_context_data(**kwargs) - context["otherApplication"] = True - list_email = "" - for u in context.get("object_list").values_list(self.email_field, flat=True): - list_email += "%s, " % u - context["emails"] = list_email - return context - - -class VolunteerApplicationsListView(IsOrganizerMixin, _OtherApplicationsListView): - table_class = VolunteerListTable - filterset_class = VolunteerFilter - - def get_queryset(self): - return models.VolunteerApplication.objects.all() - - def get_current_tabs(self): - return volunteer_tabs(self.request.user) - - -class SponsorApplicationsListView( - HaveSponsorPermissionMixin, _OtherApplicationsListView -): - table_class = SponsorListTable - filterset_class = SponsorFilter - email_field = "email" - - def get_queryset(self): - return models.SponsorApplication.objects.all() - - def get_context_data(self, **kwargs): - context = super(SponsorApplicationsListView, self).get_context_data(**kwargs) - context["otherApplication"] = True - return context - - def get_current_tabs(self): - return sponsor_tabs(self.request.user) - - -class SponsorUserListView( - HaveSponsorPermissionMixin, TabsViewMixin, ExportMixin, SingleTableMixin, FilterView -): - template_name = "applications_list.html" - table_pagination = {"per_page": 100} - exclude_columns = ("detail", "status") - export_name = "applications" - table_class = SponsorUserListTable - filterset_class = SponsorUserFilter - - def get_current_tabs(self): - return sponsor_tabs(self.request.user) - - def get_context_data(self, **kwargs): - context = super(SponsorUserListView, self).get_context_data(**kwargs) - context["otherApplication"] = True - context["createUser"] = True - return context - - def get_queryset(self): - return User.objects.filter(type=USR_SPONSOR).exclude(max_applications=0) - - -class MentorApplicationsListView(HaveMentorPermissionMixin, _OtherApplicationsListView): - table_class = MentorListTable - filterset_class = MentorFilter - - def get_queryset(self): - return models.MentorApplication.objects.all() - - def get_current_tabs(self): - return mentor_tabs(self.request.user) - - class ReviewVolunteerApplicationView(IsOrganizerMixin, TabsViewMixin, TemplateView): template_name = "other_application_detail.html" diff --git a/reimbursement/views/__init__.py b/reimbursement/views/__init__.py new file mode 100644 index 000000000..8a96b5a2c --- /dev/null +++ b/reimbursement/views/__init__.py @@ -0,0 +1,13 @@ +from reimbursement.views.hacker import ReimbursementHacker +from reimbursement.views.organizer import ( + ReimbursementDetail, + ReceiptReview, + ReimbursementListView, + SendReimbursementListView, +) + +__all__ = [ + 'ReimbursementHacker', + 'ReimbursementDetail', 'ReceiptReview', + 'ReimbursementListView', 'SendReimbursementListView', +] diff --git a/reimbursement/views/hacker.py b/reimbursement/views/hacker.py new file mode 100644 index 000000000..5effa5e1f --- /dev/null +++ b/reimbursement/views/hacker.py @@ -0,0 +1,83 @@ +from django.contrib import messages +from django.http import HttpResponseRedirect, Http404 +from django.shortcuts import render + +from app.utils import reverse, hacker_tabs +from app.views import TabsView +from reimbursement import forms, models +from user.mixins import IsHackerMixin + + +class ReimbursementHacker(IsHackerMixin, TabsView): + template_name = "reimbursement_hacker.html" + + def get_current_tabs(self): + return hacker_tabs(self.request.user) + + def get_context_data(self, **kwargs): + c = super(ReimbursementHacker, self).get_context_data(**kwargs) + reimb = getattr(self.request.user, "reimbursement", None) + if not reimb: + raise Http404 + c.update( + { + "form": forms.ReceiptSubmissionReceipt( + instance=self.request.user.reimbursement + ) + } + ) + return c + + def post(self, request, *args, **kwargs): + # check reimbursment status and act accordingly + # if status is pending demo link, then validate the devpost link + # if status is pending receipt, then validate the receipt + if request.user.reimbursement.status == models.RE_PEND_TICKET: + try: + form = forms.ReceiptSubmissionReceipt( + request.POST, request.FILES, instance=request.user.reimbursement + ) + except Exception: + form = forms.ReceiptSubmissionReceipt(request.POST, request.FILES) + if form.is_valid(): + reimb = form.save(commit=False) + reimb.hacker = request.user + # set status to pending demo link + reimb.status = models.RE_PEND_APPROVAL + reimb.save() + messages.success( + request, + "We have now received your reimbursement. " + "Processing will take some time, so please be patient.", + ) + return HttpResponseRedirect(reverse("reimbursement_dashboard")) + else: + c = self.get_context_data() + c.update({"form": form}) + return render(request, self.template_name, c) + else: + try: + form = forms.DevpostValidationForm( + request.POST, instance=request.user.reimbursement + ) + except Exception: + form = forms.DevpostValidationForm(request.POST) + if form.is_valid(): + print("valid") + reimb = form.save(commit=False) + reimb.devpost = form.cleaned_data.get("devpost") + reimb.status = models.RE_PEND_DEMO_VAL + reimb.save() + messages.success( + request, + "We have now received your demo link. " + "Processing will take some time, so please be patient.", + ) + + return HttpResponseRedirect(reverse("reimbursement_dashboard")) + else: + print(form.errors) + print("invalid") + c = self.get_context_data() + c.update({"form": form}) + return render(request, self.template_name, c) diff --git a/reimbursement/views.py b/reimbursement/views/organizer.py similarity index 77% rename from reimbursement/views.py rename to reimbursement/views/organizer.py index 99a63af11..788feb73a 100644 --- a/reimbursement/views.py +++ b/reimbursement/views/organizer.py @@ -1,12 +1,12 @@ from django.contrib import messages from django.core.exceptions import ValidationError -from django.http import HttpResponseRedirect, Http404 +from django.http import HttpResponseRedirect from django.shortcuts import render, get_object_or_404 from django_filters.views import FilterView from django_tables2 import SingleTableMixin from app.mixins import TabsViewMixin -from app.utils import reverse, hacker_tabs +from app.utils import reverse from app.views import TabsView from applications import models as app_mod from applications.emails import send_batch_emails @@ -17,85 +17,10 @@ SendReimbursementTable, SendReimbursementFilter, ) -from user.mixins import IsOrganizerMixin, IsDirectorMixin, IsHackerMixin +from user.mixins import IsOrganizerMixin, IsDirectorMixin from organizers.views import hacker_tabs as organizer_tabs -class ReimbursementHacker(IsHackerMixin, TabsView): - template_name = "reimbursement_hacker.html" - - def get_current_tabs(self): - return hacker_tabs(self.request.user) - - def get_context_data(self, **kwargs): - c = super(ReimbursementHacker, self).get_context_data(**kwargs) - reimb = getattr(self.request.user, "reimbursement", None) - if not reimb: - raise Http404 - c.update( - { - "form": forms.ReceiptSubmissionReceipt( - instance=self.request.user.reimbursement - ) - } - ) - return c - - def post(self, request, *args, **kwargs): - # check reimbursment status and act accordingly - # if status is pending demo link, then validate the devpost link - # if status is pending receipt, then validate the receipt - if request.user.reimbursement.status == models.RE_PEND_TICKET: - try: - form = forms.ReceiptSubmissionReceipt( - request.POST, request.FILES, instance=request.user.reimbursement - ) - except Exception: - form = forms.ReceiptSubmissionReceipt(request.POST, request.FILES) - if form.is_valid(): - reimb = form.save(commit=False) - reimb.hacker = request.user - # set status to pending demo link - reimb.status = models.RE_PEND_APPROVAL - reimb.save() - messages.success( - request, - "We have now received your reimbursement. " - "Processing will take some time, so please be patient.", - ) - return HttpResponseRedirect(reverse("reimbursement_dashboard")) - else: - c = self.get_context_data() - c.update({"form": form}) - return render(request, self.template_name, c) - else: - try: - form = forms.DevpostValidationForm( - request.POST, instance=request.user.reimbursement - ) - except Exception: - form = forms.DevpostValidationForm(request.POST) - if form.is_valid(): - print("valid") - reimb = form.save(commit=False) - reimb.devpost = form.cleaned_data.get("devpost") - reimb.status = models.RE_PEND_DEMO_VAL - reimb.save() - messages.success( - request, - "We have now received your demo link. " - "Processing will take some time, so please be patient.", - ) - - return HttpResponseRedirect(reverse("reimbursement_dashboard")) - else: - print(form.errors) - print("invalid") - c = self.get_context_data() - c.update({"form": form}) - return render(request, self.template_name, c) - - class ReimbursementDetail(IsOrganizerMixin, TabsView): template_name = "reimbursement_detail.html" diff --git a/stats/static/css/stats.css b/stats/static/css/stats.css new file mode 100644 index 000000000..6d9b9cc65 --- /dev/null +++ b/stats/static/css/stats.css @@ -0,0 +1,15 @@ +.c3-axis-y text { + fill: #000000; +} +.c3-axis-x text { + fill: #000000; +} +.c3-legend-item { + fill: #000000; +} +.c3 .c3-axis line, .c3 .c3-axis path { + stroke: #000000; +} +.c3-tooltip tr { + color: #222222; +} diff --git a/stats/templates/c3_base.html b/stats/templates/c3_base.html index 682d3803f..e6a2915f0 100644 --- a/stats/templates/c3_base.html +++ b/stats/templates/c3_base.html @@ -4,6 +4,7 @@ {% block extra_head %} + {% endblock %} {% block extra_scripts %} diff --git a/stats/templates/c3_table_base.html b/stats/templates/c3_table_base.html index dd5ca3542..608de24e3 100644 --- a/stats/templates/c3_table_base.html +++ b/stats/templates/c3_table_base.html @@ -4,6 +4,7 @@ {% block extra_head %} + {% endblock %} {% block extra_scripts %} diff --git a/user/views/__init__.py b/user/views/__init__.py new file mode 100644 index 000000000..f0afb9aaf --- /dev/null +++ b/user/views/__init__.py @@ -0,0 +1,15 @@ +from user.views.authentication import ( + login, signup, Logout, activate, + password_reset, password_reset_confirm, password_reset_complete, password_reset_done, + verify_email_required, set_password, send_email_verification, + callback, SponsorRegister, +) +from user.views.profile import UserProfile, DeleteAccount + +__all__ = [ + 'login', 'signup', 'Logout', 'activate', + 'password_reset', 'password_reset_confirm', 'password_reset_complete', 'password_reset_done', + 'verify_email_required', 'set_password', 'send_email_verification', + 'callback', 'SponsorRegister', + 'UserProfile', 'DeleteAccount', +] diff --git a/user/views.py b/user/views/authentication.py similarity index 87% rename from user/views.py rename to user/views/authentication.py index a7978aa49..d486bd69d 100644 --- a/user/views.py +++ b/user/views/authentication.py @@ -16,7 +16,7 @@ from applications import models as a_models from user import forms, models, tokens, providers from user.forms import SetPasswordForm, PasswordResetForm -from user.mixins import HaveSponsorPermissionMixin, IsHackerMixin +from user.mixins import HaveSponsorPermissionMixin from user.models import User from user.verification import check_recaptcha, check_client_ip, reset_tries @@ -293,43 +293,3 @@ def post(self, request, *args, **kwargs): context = self.get_context_data() context.update({'form': form}) return TemplateResponse(request, self.template_name, context) - - -class UserProfile(IsHackerMixin, TemplateView): - template_name = 'profile.html' - - def get_context_data(self, *args, **kwargs): - context = super(UserProfile, self).get_context_data(**kwargs) - form = forms.ProfileForm(initial={ - 'name': self.request.user.name, - 'email': self.request.user.email, - 'type': self.request.user.type if self.request.user.can_change_type() else 'H', - 'non_change_type': self.request.user.get_type_display(), - }, type_active=self.request.user.can_change_type()) - context.update({'form': form}) - return context - - def post(self, request, *args, **kwargs): - form = forms.ProfileForm(request.POST, type_active=request.user.can_change_type()) - if form.is_valid(): - name = form.cleaned_data['name'] - request.user.name = name - if request.user.can_change_type(): - type = form.cleaned_data['type'] - request.user.type = type - request.user.save() - messages.success(request, "Profile saved successfully") - c = self.get_context_data() - else: - c = self.get_context_data() - c.update({'form': form}) - return render(request, self.template_name, c) - - -class DeleteAccount(IsHackerMixin, TemplateView): - template_name = 'confirm_delete.html' - - def post(self, request, *args, **kwargs): - request.user.delete() - messages.success(request, "User deleted successfully") - return HttpResponseRedirect(reverse('root')) diff --git a/user/views/profile.py b/user/views/profile.py new file mode 100644 index 000000000..aac9f3287 --- /dev/null +++ b/user/views/profile.py @@ -0,0 +1,48 @@ +from django.contrib import messages +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.views.generic import TemplateView + +from app.utils import reverse +from user import forms +from user.mixins import IsHackerMixin + + +class UserProfile(IsHackerMixin, TemplateView): + template_name = 'profile.html' + + def get_context_data(self, *args, **kwargs): + context = super(UserProfile, self).get_context_data(**kwargs) + form = forms.ProfileForm(initial={ + 'name': self.request.user.name, + 'email': self.request.user.email, + 'type': self.request.user.type if self.request.user.can_change_type() else 'H', + 'non_change_type': self.request.user.get_type_display(), + }, type_active=self.request.user.can_change_type()) + context.update({'form': form}) + return context + + def post(self, request, *args, **kwargs): + form = forms.ProfileForm(request.POST, type_active=request.user.can_change_type()) + if form.is_valid(): + name = form.cleaned_data['name'] + request.user.name = name + if request.user.can_change_type(): + type = form.cleaned_data['type'] + request.user.type = type + request.user.save() + messages.success(request, "Profile saved successfully") + c = self.get_context_data() + else: + c = self.get_context_data() + c.update({'form': form}) + return render(request, self.template_name, c) + + +class DeleteAccount(IsHackerMixin, TemplateView): + template_name = 'confirm_delete.html' + + def post(self, request, *args, **kwargs): + request.user.delete() + messages.success(request, "User deleted successfully") + return HttpResponseRedirect(reverse('root')) From f18287399f25859b04b2f39a4840f21df811b56a Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Fri, 3 Apr 2026 09:54:39 +0200 Subject: [PATCH 2/5] refactor: split views, reorganize CSS, add docs for new organizers --- docs/contributing.md | 39 +- .../plans/2026-04-02-codebase-refactor.md | 1046 ----------------- .../2026-04-02-codebase-refactor-design.md | 192 --- 3 files changed, 21 insertions(+), 1256 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-02-codebase-refactor.md delete mode 100644 docs/superpowers/specs/2026-04-02-codebase-refactor-design.md diff --git a/docs/contributing.md b/docs/contributing.md index 86b8e5d11..77ed2f54a 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -52,11 +52,13 @@ Start the dev server, go to the hacker application form, and confirm the new fie Same pattern as the hacker application, different files: -| Layer | Volunteer | Mentor | -|---|---|---| -| Model | `applications/models/volunteer.py` | `applications/models/mentor.py` | -| Form | `applications/forms/volunteer.py` | `applications/forms/mentor.py` | -| Template | `applications/templates/` (look for volunteer/mentor form template) | same | + +| Layer | Volunteer | Mentor | +| -------- | ------------------------------------------------------------------- | ------------------------------- | +| Model | `applications/models/volunteer.py` | `applications/models/mentor.py` | +| Form | `applications/forms/volunteer.py` | `applications/forms/mentor.py` | +| Template | `applications/templates/` (look for volunteer/mentor form template) | same | + Run `python manage.py makemigrations && python manage.py migrate` after changing the model. @@ -72,16 +74,17 @@ Find and update the reimbursement limit variables. No migration or code change n ## Where to Look for Other Things -| What you want to change | Where to look | -|---|---| -| Application status flow (pending → invited → confirmed) | `applications/models/base.py` | -| Who can review applications and vote | `organizers/views/review.py` | -| Batch invite algorithm | `organizers/views/batch_ops.py` | -| Email content | See `docs/email-templates.md` | -| Check-in logic | `checkin/views.py` | -| User roles and permissions | `user/models.py`, `user/mixins.py` | -| Hackathon name, dates, deadlines | `app/hackathon_variables.py` | -| Global styles | `app/static/css/main.css` | -| Bootstrap component styles | `app/static/css/bootstrap-overrides/` | - -> **Note:** `organizers/views/review.py` and `organizers/views/batch_ops.py` are created as part of this refactor. Until the refactor is complete, this logic lives in `organizers/views.py`. + +| What you want to change | Where to look | +| ------------------------------------------------------- | ------------------------------------- | +| Application status flow (pending → invited → confirmed) | `applications/models/base.py` | +| Who can review applications and vote | `organizers/views/review.py` | +| Batch invite algorithm | `organizers/views/batch_ops.py` | +| Email content | See `docs/email-templates.md` | +| Check-in logic | `checkin/views.py` | +| User roles and permissions | `user/models.py`, `user/mixins.py` | +| Hackathon name, dates, deadlines | `app/hackathon_variables.py` | +| Global styles | `app/static/css/main.css` | +| Bootstrap component styles | `app/static/css/bootstrap-overrides/` | + + diff --git a/docs/superpowers/plans/2026-04-02-codebase-refactor.md b/docs/superpowers/plans/2026-04-02-codebase-refactor.md deleted file mode 100644 index 0c2ab5511..000000000 --- a/docs/superpowers/plans/2026-04-02-codebase-refactor.md +++ /dev/null @@ -1,1046 +0,0 @@ -# Codebase Refactor Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Reorganize the myhackupc Django codebase so that new volunteer developers can navigate it, find what they need to change, and make changes confidently without breaking things. - -**Architecture:** Split large `views.py` files into `views/` packages (one file per concern), push app-specific CSS out of global files into each app's `static/` folder, split the minified Bootstrap CSS into readable component files, and create a `docs/` folder with onboarding and how-to guides. - -**Tech Stack:** Django 3.2, Python 3.10, Bootstrap 3, WhiteNoise for static files, SendGrid for email. - -**Spec:** `docs/superpowers/specs/2026-04-02-codebase-refactor-design.md` - ---- - -## Important: How to Verify After Each Task - -This refactor does not add new behavior — every change is structural. There are no unit tests to run. Instead, use these verification steps after every task: - -- **Dev server check:** `python manage.py runserver` — should start with no import errors -- **Management command check** (only after Task 9): `python manage.py expire_applications --help` -- **CSS visual check:** Load the app in a browser and verify the page looks correct -- **Import grep:** After splitting a views file, run `grep -r "from .views" . --include="*.py"` to confirm all callers still resolve - ---- - -## Phase 1: Documentation - ---- - -### Task 1: Create `docs/getting-started.md` - -**Files:** -- Create: `docs/getting-started.md` - -- [ ] **Step 1: Create the file with this content** - -```markdown -# Getting Started - -myhackupc is the registration and event management tool for HackUPC. It is a Django web application used by three groups: - -- **Hackers** — apply, confirm attendance, request travel reimbursement -- **Organizers** — review applications, vote, send invites, manage check-in, hardware, meals, baggage -- **Volunteers/Mentors** — access check-in and their own application flow - ---- - -## Codebase Structure - -The project is a standard Django multi-app repository. Each feature lives in its own app folder. Here is what each active app does: - -| App | Purpose | -|---|---| -| `app/` | Core config: settings, base templates, global CSS, shared utilities, hackathon variables | -| `applications/` | Hacker, volunteer, mentor, and sponsor application forms and status flow | -| `organizers/` | Application review, voting, batch invites, waitlist/blacklist management | -| `user/` | Authentication: sign up, login, password reset, email verification, user roles | -| `reimbursement/` | Travel reimbursement requests, receipt upload, organizer approval flow | -| `checkin/` | QR-code-based event check-in for hackers, volunteers, and mentors | -| `baggage/` | Baggage drop-off and pick-up tracking | -| `hardware/` | Hardware lab lending requests | -| `meals/` | Meal distribution tracking per hacker | -| `teams/` | Team formation and membership | -| `stats/` | Organizer-facing analytics dashboards | - -Excluded from active development: `judging`, `offer`, `discord`. - ---- - -## Where Things Live - -### Templates -Each app has its own `templates/` folder. For example: -- Hacker application form: `applications/templates/` -- Organizer review interface: `organizers/templates/` -- Email templates: `/templates/mails/` in the relevant app - -### Styles -- Global layout, typography, theme colors: `app/static/css/main.css` -- Bootstrap component overrides: `app/static/css/bootstrap-overrides/` (one file per component) -- App-specific styles: `/static/css/.css` (e.g. `baggage/static/css/baggage.css`) - -### URLs -| URL prefix | App | -|---|---| -| `/` | `applications` (hacker dashboard) | -| `/applications/` | `organizers` (review interface) | -| `/user/` | `user` (auth) | -| `/reimbursement/` | `reimbursement` | -| `/checkin/` | `checkin` | -| `/baggage/` | `baggage` | -| `/hardware/` | `hardware` | -| `/meals/` | `meals` | -| `/teams/` | `teams` | -| `/stats/` | `stats` | -| `/admin/` | Django admin | - ---- - -## The Most Important Config File - -**`app/hackathon_variables.py`** — this is what you touch every year. It contains: -- Application open/close deadlines -- Reimbursement cap amounts -- Feature flags (baggage, hardware, reimbursement, discord) -- Hackathon name, domain, email addresses -- Slack channel IDs - -A template with all available variables is at `app/hackathon_variables.py.template`. - ---- - -## Next Steps - -- **Set up your local environment:** see `docs/setting-up.md` -- **Make a change:** see `docs/contributing.md` -- **Edit an email template:** see `docs/email-templates.md` -``` - -- [ ] **Step 2: Verify the file was created** - -```bash -ls docs/ -``` - ---- - -### Task 2: Create `docs/setting-up.md` and update README - -**Files:** -- Create: `docs/setting-up.md` -- Modify: `README.md` (add pointer to docs at the top of the Setup section) - -- [ ] **Step 1: Create `docs/setting-up.md`** - -Extract the setup instructions from `README.md` (the "Setup", "Available environment variables", "Server > Local environment", and "Dummy data" sections) and write them here in a single cohesive guide. Use this structure: - -```markdown -# Setting Up myhackupc Locally - -## Prerequisites - -- Python 3.10 -- `virtualenv` - ---- - -## Installation - -```bash -git clone https://github.com/hackupc/myhackupc && cd myhackupc -virtualenv env --python=python3.10 -source ./env/bin/activate -pip install -r requirements.txt -``` - -> **Note on psycopg2-binary:** If you get an error, install openssl@3 and export LDFLAGS, CPPFLAGS, and PKG_CONFIG_PATH before running pip install. - ---- - -## Configuration - -Copy the hackathon variables template and fill in the values: - -```bash -cp app/hackathon_variables.py.template app/hackathon_variables.py -``` - -Open `app/hackathon_variables.py` and set at minimum: -- `HACKATHON_NAME` -- `HACKATHON_DOMAIN` (use `localhost:8000` for local dev) -- Application deadlines - ---- - -## Database - -```bash -python manage.py migrate -python manage.py createsuperuser -``` - ---- - -## Running the Dev Server - -```bash -source ./env/bin/activate -python manage.py runserver -``` - -Visit http://localhost:8000. Log in with the superuser you created. - ---- - -## Environment Variables (Optional) - -| Variable | Purpose | -|---|---| -| `SG_KEY` | SendGrid API key. If not set, emails are written to the filesystem at `sent_emails/` | -| `PROD_MODE` | Disables Django debug mode | -| `SECRET` | Web app secret key | -| `DATABASE_URL` | Database connection URL. Defaults to SQLite | -| `SL_TOKEN` | Slack token for automatic invites on confirmation | -| `DROPBOX_OAUTH2_TOKEN` | Enables Dropbox as file storage backend | -| `MLH_CLIENT_SECRET` | Enables MyMLH sign-up (`app_id@secret` format) | -| `CAS_SERVER` | Enables CAS login for other platforms | - -See `README.md` for the full list and Heroku/production deployment instructions. - ---- - -## Verifying Your Changes - -After making code changes: - -1. Restart the dev server (`Ctrl+C`, then `python manage.py runserver`) -2. Navigate to the affected page in the browser -3. If you changed a model, run `python manage.py makemigrations && python manage.py migrate` -4. If you changed CSS, hard-refresh the browser (Ctrl+Shift+R / Cmd+Shift+R) -``` - -- [ ] **Step 2: Add a pointer to `docs/` at the top of the Setup section in `README.md`** - -Find the `## Setup` heading in `README.md` and add this line immediately after it: - -```markdown -> For a step-by-step guide see [docs/setting-up.md](docs/setting-up.md). For codebase orientation see [docs/getting-started.md](docs/getting-started.md). -``` - -- [ ] **Step 3: Verify dev server still starts** - -```bash -python manage.py runserver -``` - -Expected: starts without errors. - ---- - -### Task 3: Create `docs/contributing.md` - -**Files:** -- Create: `docs/contributing.md` - -- [ ] **Step 1: Create the file with this content** - -```markdown -# Contributing — Common Changes - -This document walks through the most common yearly changes with exact file paths. - ---- - -## Example 1: Adding or Removing a Field from the Hacker Application - -The hacker application form is split across three layers: model, form, and template. - -### 1. Update the model - -File: `applications/models/hacker.py` - -Add or remove the field from the `HackerApplication` model class. Use standard Django field types. Example: - -```python -# Adding a new field -dietary_restrictions = models.CharField( - max_length=200, - blank=True, - help_text="Any dietary restrictions we should know about?" -) -``` - -### 2. Create and run a migration - -```bash -python manage.py makemigrations -python manage.py migrate -``` - -### 3. Update the form - -File: `applications/forms/hacker.py` - -Add the field name to the `fields` list in `HackerApplicationForm.Meta`, and optionally customize its widget or label in the `widgets` or `labels` dicts. - -### 4. Update the template - -File: `applications/templates/include/application_form.html` (or the relevant template — look for the `{% for field in form %}` loop or the specific field rendering) - -If you need custom layout for the new field, add it explicitly. Otherwise it will be rendered automatically by the form loop. - -### 5. Verify - -Start the dev server, go to the hacker application form, and confirm the new field appears. - ---- - -## Example 2: Adding or Removing a Field from the Volunteer or Mentor Application - -Same pattern as the hacker application, different files: - -| Layer | Volunteer | Mentor | -|---|---|---| -| Model | `applications/models/volunteer.py` | `applications/models/mentor.py` | -| Form | `applications/forms/volunteer.py` | `applications/forms/mentor.py` | -| Template | `applications/templates/` (look for volunteer/mentor form template) | same | - -Run `python manage.py makemigrations && python manage.py migrate` after changing the model. - ---- - -## Example 3: Changing the Travel Reimbursement Cap - -File: `app/hackathon_variables.py` - -Find and update the reimbursement limit variables. No migration or code change needed — these are config values read at runtime. - ---- - -## Where to Look for Other Things - -| What you want to change | Where to look | -|---|---| -| Application status flow (pending → invited → confirmed) | `applications/models/base.py` | -| Who can review applications and vote | `organizers/views/review.py` | -| Batch invite algorithm | `organizers/views/batch_ops.py` | -| Email content | See `docs/email-templates.md` | -| Check-in logic | `checkin/views.py` | -| User roles and permissions | `user/models.py`, `user/mixins.py` | -| Hackathon name, dates, deadlines | `app/hackathon_variables.py` | -| Global styles | `app/static/css/main.css` | -| Bootstrap component styles | `app/static/css/bootstrap-overrides/` | -``` - ---- - -### Task 4: Create `docs/email-templates.md` - -**Files:** -- Create: `docs/email-templates.md` - -- [ ] **Step 1: Create the file with this content** - -````markdown -# Email Templates - -myhackupc sends emails using Django's template system. Each email consists of two template files: -- `*_subject.txt` — the subject line (one line of text, no HTML) -- `*_message.html` — the email body (HTML) - ---- - -## All Emails the System Sends - -| Email | Sent when | App | Template path | -|---|---|---|---| -| Hacker invitation | Organizer sends batch invites | `applications` | `applications/templates/mails/invitation_hacker_*` | -| Mentor invitation | (currently disabled) | `applications` | `applications/templates/mails/invitation_mentor_*` | -| Volunteer invitation | (currently disabled) | `applications` | `applications/templates/mails/invitation_volunteer_*` | -| Confirmation (ticket) | Hacker confirms attendance | `applications` | `applications/templates/mails/confirmation_*` | -| Last reminder | Reminder before confirmation deadline | `applications` | `applications/templates/mails/last_reminder_*` | -| Reimbursement approved | Organizer approves reimbursement | `reimbursement` | `reimbursement/templates/mails/reimbursement_*` | -| Receipt rejected | Organizer rejects uploaded receipt | `reimbursement` | `reimbursement/templates/mails/reject_receipt_*` | -| No reimbursement | Hacker not eligible for reimbursement | `reimbursement` | `reimbursement/templates/mails/no_reimbursement_*` | -| Travel tickets upload | Ask hacker to upload travel receipts | `reimbursement` | `reimbursement/templates/mails/travel_tickets_upload_*` | -| Ticket accepted | Receipt accepted | `reimbursement` | `reimbursement/templates/mails/ticket_accepted_*` | -| Devpost upload | Ask hacker to upload Devpost link | `reimbursement` | `reimbursement/templates/mails/devpost_upload_*` | -| Project invalidated | Devpost project rejected | `reimbursement` | `reimbursement/templates/mails/project_invalidated_*` | -| Devpost approved | Devpost project approved | `reimbursement` | `reimbursement/templates/mails/devpost_approved_*` | -| Email verification | User registers | `user` | `user/templates/mails/verify_email_*` | -| Password reset | User requests password reset | `user` | `user/templates/mails/password_reset_*` | -| Sponsor link | Sponsor account linked | `user` | `user/templates/mails/sponsor_link_*` | - -Shared email components (footer, buttons, images) are in `app/templates/mails/include/`. - ---- - -## How to Edit Email Content - -1. Find the email in the table above -2. Open the `*_message.html` file — this is the email body -3. Edit the HTML content. You can use Django template tags (`{{ variable }}`, `{% if %}`, etc.) -4. To change the subject line, edit the `*_subject.txt` file - ---- - -## Variables Available in Each Template - -Each email has a set of variables passed to it from the Python code. Here is where those variables are defined: - -| Email group | Python file | Context variables | -|---|---|---| -| Application emails (invite, confirmation, reminder) | `applications/emails.py` | `name`, `reimb`, `confirm_url`, `cancel_url`, `hybrid_option`, `token`, `qr_url` | -| Reimbursement emails | `reimbursement/emails.py` | `app`, `reimb`, `confirm_url`, `form_url`, `cancel_url` | -| User emails | `user/emails.py` | varies per email — read the function | - ---- - -## How to Add a New Variable to an Email - -Example: you want to add `{{ deadline }}` to the hacker invitation email. - -### Step 1: Find the Python function that creates the email - -Open `applications/emails.py` and find `create_invite_email`. You will see a `c = { ... }` dictionary — this is the context passed to the template. - -### Step 2: Add the variable to the context dict - -```python -c = { - 'name': application.user.get_full_name, - 'reimb': getattr(application.user, 'reimbursement', None), - 'confirm_url': str(reverse('dashboard', request=request)), - 'cancel_url': str(reverse('cancel_app', request=request, kwargs={'id': application.uuid_str})), - 'hybrid_option': 'Online' if getattr(application, 'online', False) else 'Live', - 'deadline': settings.HACKATHON_END, # ← add your variable here -} -``` - -### Step 3: Use the variable in the template - -Open `applications/templates/mails/invitation_hacker_message.html` and add: - -```html -

The deadline to confirm is {{ deadline }}.

-``` - -### Step 4: Verify - -Trigger the email in local dev (or use the Django shell) and confirm the variable renders correctly. -```` - ---- - -## Phase 2: CSS Reorganization - ---- - -### Task 5: Split `custom-bootstrap.css` into component files - -**Files:** -- Modify: `app/templates/base.html` (replace single `` with multiple) -- Create: `app/static/css/bootstrap-overrides/variables.css` -- Create: `app/static/css/bootstrap-overrides/print.css` -- Create: `app/static/css/bootstrap-overrides/glyphicons.css` -- Create: `app/static/css/bootstrap-overrides/scaffolding.css` -- Create: `app/static/css/bootstrap-overrides/typography.css` -- Create: `app/static/css/bootstrap-overrides/grid.css` -- Create: `app/static/css/bootstrap-overrides/tables.css` -- Create: `app/static/css/bootstrap-overrides/forms.css` -- Create: `app/static/css/bootstrap-overrides/buttons.css` -- Create: `app/static/css/bootstrap-overrides/nav.css` -- Create: `app/static/css/bootstrap-overrides/dropdowns.css` -- Create: `app/static/css/bootstrap-overrides/alerts.css` -- Create: `app/static/css/bootstrap-overrides/modals.css` -- Create: `app/static/css/bootstrap-overrides/misc.css` - -- [ ] **Step 1: Un-minify `custom-bootstrap.css`** - -The file is currently minified (all on a few lines). Before splitting, format it so each rule is on its own line: - -```bash -# Install prettier if not available -npm install -g prettier -# Format the file in-place -prettier --write app/static/css/custom-bootstrap.css --parser css -``` - -If npm is not available, paste the file contents into https://www.cleancss.com/css-beautify/ and replace the file. The un-minified version will be easier to split and maintain going forward. - -- [ ] **Step 2: Create `app/static/css/bootstrap-overrides/` directory** - -```bash -mkdir -p app/static/css/bootstrap-overrides -``` - -- [ ] **Step 3: Split the file into component files** - -Open `app/static/css/custom-bootstrap.css`. Working from top to bottom, cut each Bootstrap component section into its own file. Use the following CSS class names to identify section boundaries: - -| File | Starts at / contains | -|---|---| -| `variables.css` | `html {`, `body {`, base resets at the top of the file | -| `print.css` | `@media print {` | -| `glyphicons.css` | `.glyphicon` | -| `scaffolding.css` | `.container`, `.container-fluid`, row/col classes | -| `typography.css` | `h1,h2,h3`, `.lead`, `.small`, `blockquote`, `code`, `pre` | -| `grid.css` | `.col-xs-`, `.col-sm-`, `.col-md-`, `.col-lg-` | -| `tables.css` | `.table` | -| `forms.css` | `.form-group`, `.form-control`, `.input-group`, `.checkbox`, `.radio` | -| `buttons.css` | `.btn`, `.btn-default`, `.btn-primary` etc. | -| `nav.css` | `.nav`, `.navbar`, `.breadcrumb`, `.pagination`, `.pager`, `.tabs` | -| `dropdowns.css` | `.dropdown`, `.caret`, `.open` | -| `alerts.css` | `.alert`, `.label`, `.badge` | -| `modals.css` | `.modal`, `.fade`, `.in`, `.backdrop` | -| `misc.css` | Everything else (panels, wells, thumbnails, progress bars, list-groups, jumbotron, close, etc.) | - -Each file should start with a comment: -```css -/* Bootstrap 3 overrides — */ -``` - -- [ ] **Step 4: Update `base.html` to load the component files** - -In `app/templates/base.html`, find line 53: -```html - -``` - -Replace it with one `` per component file, **in the same order** as the sections appeared in the original file: - -```html - - - - - - - - - - - - - - -``` - -- [ ] **Step 5: Collect static files and visual check** - -```bash -python manage.py collectstatic --noinput -python manage.py runserver -``` - -Open the app in a browser. The page should look exactly the same as before. Check: -- Navbar renders correctly -- Buttons have correct colours and style -- Forms look correct -- Any modal dialogs (try opening one) render correctly - -If anything looks broken, the cascade order is off — check that the component files are loaded in the same order the sections appeared in the original file. - ---- - -### Task 6: Audit `main.css` and extract app-specific styles - -**Files:** -- Modify: `app/static/css/main.css` (remove app-specific styles) -- Modify (create if needed): `organizers/static/css/organizers.css` -- Modify (create if needed): `reimbursement/static/css/reimbursement.css` - -- [ ] **Step 1: Audit `main.css`** - -Read through `app/static/css/main.css` (506 lines). For each CSS rule, ask: "Is this only used in one app's templates?" If yes, it belongs in that app's CSS file. If it's used globally (body, navbar, base layout, typography), it stays. - -Keep in `main.css`: -- Body background, font, base layout -- Navbar styles -- Footer styles -- Global utility classes used across multiple apps - -Move to app-specific files: -- Any class that only appears in templates of a single app (search with `grep -r "class-name" /templates/`) - -- [ ] **Step 2: Create app CSS files if needed and move the styles** - -If you find styles that belong only to `organizers`, create `organizers/static/css/organizers.css` and move them there. Same for `reimbursement`. - -- [ ] **Step 3: Add the CSS link to the app's base template if needed** - -For any app that gets a new CSS file, find its base template (e.g. `organizers/templates/organizers_base.html` or whichever template that app's views extend). Add a `{% block extra_head %}` block: - -```html -{% block extra_head %} - -{% endblock %} -``` - -**Important:** Templates that extend `base_tabs.html` use `{% block extra_head %}`. Templates that extend `base.html` directly use `{% block head %}`. Check which one the template extends before adding the block. - -- [ ] **Step 4: Verify** - -```bash -python manage.py runserver -``` - -Visual check: navigate to affected pages and confirm styles are unchanged. - ---- - -## Phase 3: Views Splitting - -**Pattern for every app:** Convert `views.py` → `views/` package with sub-files and an `__init__.py` that re-exports everything. No `urls.py` changes needed. - ---- - -### Task 7: Split `organizers/views.py` - -**Files:** -- Delete: `organizers/views.py` -- Create: `organizers/views/__init__.py` -- Create: `organizers/views/lists.py` -- Create: `organizers/views/review.py` -- Create: `organizers/views/batch_ops.py` - -**Class/function → file mapping:** - -`lists.py`: -- `hacker_tabs`, `volunteer_tabs`, `mentor_tabs`, `sponsor_tabs` (tab helper functions used throughout the file) -- `ApplicationsListView` -- `InviteListView` -- `WaitlistedApplicationsListView` -- `DubiousApplicationsListView` -- `BlacklistApplicationsListView` -- `_OtherApplicationsListView` -- `VolunteerApplicationsListView` -- `SponsorApplicationsListView` -- `SponsorUserListView` -- `MentorApplicationsListView` - -`review.py`: -- `add_vote`, `add_comment` (helper functions) -- `ApplicationDetailView` -- `ReviewApplicationView` -- `ReviewApplicationDetailView` -- `ReviewVolunteerApplicationView` -- `ReviewSponsorApplicationView` -- `ReviewMentorApplicationView` -- `ReviewResume` -- `VisualizeResume` - -`batch_ops.py`: -- `InviteTeamListView` (contains the core batch invite algorithm) - -- [ ] **Step 1: Create `organizers/views/` directory** - -```bash -mkdir organizers/views -``` - -- [ ] **Step 2: Create `organizers/views/lists.py`** - -Copy the imports from the top of `organizers/views.py` — all of them, because you will prune unused ones later. Then cut-paste the tab helper functions and all list view classes listed above. - -At the top of `lists.py`, add any imports needed by these classes. (Start with all imports from the original file — remove what is not used once the split is complete.) - -- [ ] **Step 3: Create `organizers/views/review.py`** - -Same process: copy all imports, cut-paste `add_vote`, `add_comment`, and all review view classes listed above. - -Note: `ReviewApplicationView` and `ReviewApplicationDetailView` both inherit from `ApplicationDetailView`, which is also in this file — no cross-file import needed. - -- [ ] **Step 4: Create `organizers/views/batch_ops.py`** - -Copy all imports, cut-paste `InviteTeamListView`. - -- [ ] **Step 5: Create `organizers/views/__init__.py`** - -This file re-exports every name that any file in the project imports from `organizers.views`. The following names are confirmed callers (grep verified): - -```python -from organizers.views.lists import ( - hacker_tabs, - volunteer_tabs, - mentor_tabs, - sponsor_tabs, - ApplicationsListView, - InviteListView, - WaitlistedApplicationsListView, - DubiousApplicationsListView, - BlacklistApplicationsListView, - _OtherApplicationsListView, - VolunteerApplicationsListView, - SponsorApplicationsListView, - SponsorUserListView, - MentorApplicationsListView, -) -from organizers.views.review import ( - add_vote, - add_comment, - ApplicationDetailView, - ReviewApplicationView, - ReviewApplicationDetailView, - ReviewVolunteerApplicationView, - ReviewSponsorApplicationView, - ReviewMentorApplicationView, - ReviewResume, - VisualizeResume, -) -from organizers.views.batch_ops import ( - InviteTeamListView, -) - -__all__ = [ - 'hacker_tabs', 'volunteer_tabs', 'mentor_tabs', 'sponsor_tabs', - 'ApplicationsListView', 'InviteListView', 'WaitlistedApplicationsListView', - 'DubiousApplicationsListView', 'BlacklistApplicationsListView', - '_OtherApplicationsListView', 'VolunteerApplicationsListView', - 'SponsorApplicationsListView', 'SponsorUserListView', 'MentorApplicationsListView', - 'add_vote', 'add_comment', - 'ApplicationDetailView', 'ReviewApplicationView', 'ReviewApplicationDetailView', - 'ReviewVolunteerApplicationView', 'ReviewSponsorApplicationView', - 'ReviewMentorApplicationView', 'ReviewResume', 'VisualizeResume', - 'InviteTeamListView', -] -``` - -- [ ] **Step 6: Delete the original `organizers/views.py`** - -```bash -rm organizers/views.py -``` - -- [ ] **Step 7: Verify all callers are satisfied** - -```bash -grep -r "from organizers.views" . --include="*.py" -grep -r "from organizers import views" . --include="*.py" -``` - -Confirm every import resolves to a name that `__init__.py` exports. - -- [ ] **Step 8: Start the dev server** - -```bash -python manage.py runserver -``` - -Expected: starts with no import errors. Navigate to `/applications/` (organizer view) and confirm it loads. - ---- - -### Task 8: Split `reimbursement/views.py` - -**Files:** -- Delete: `reimbursement/views.py` -- Create: `reimbursement/views/__init__.py` -- Create: `reimbursement/views/hacker.py` -- Create: `reimbursement/views/organizer.py` - -**Class → file mapping:** - -`hacker.py`: -- `ReimbursementHacker` - -`organizer.py`: -- `ReimbursementDetail` -- `ReceiptReview` (inherits from `ReimbursementDetail` — both must be in the same file) -- `ReimbursementListView` -- `SendReimbursementListView` - -- [ ] **Step 1: Create `reimbursement/views/` directory and sub-files** - -Follow the same pattern as Task 7: create the directory, create `hacker.py` and `organizer.py` with the relevant classes, create `__init__.py` with re-exports, delete the original `views.py`. - -- [ ] **Step 2: Create `reimbursement/views/__init__.py`** - -```python -from reimbursement.views.hacker import ReimbursementHacker -from reimbursement.views.organizer import ( - ReimbursementDetail, - ReceiptReview, - ReimbursementListView, - SendReimbursementListView, -) - -__all__ = [ - 'ReimbursementHacker', - 'ReimbursementDetail', 'ReceiptReview', - 'ReimbursementListView', 'SendReimbursementListView', -] -``` - -- [ ] **Step 3: Verify callers and start dev server** - -```bash -grep -r "from reimbursement.views" . --include="*.py" -grep -r "from reimbursement import views" . --include="*.py" -python manage.py runserver -``` - ---- - -### Task 9: Split `applications/views.py` - -**Files:** -- Delete: `applications/views.py` -- Create: `applications/views/__init__.py` -- Create: `applications/views/hacker.py` -- Create: `applications/views/sponsor.py` -- Create: `applications/views/mentor.py` -- Create: `applications/views/draft.py` - -**Class/function → file mapping:** - -`hacker.py`: -- `VIEW_APPLICATION_TYPE` (constant — also imported by management command) -- `VIEW_APPLICATION_FORM_TYPE` (constant) -- `check_application_exists` (helper) -- `get_deadline` (helper) -- `user_is_in_blacklist` (helper) -- `ConfirmApplication` -- `CancelApplication` -- `HackerDashboard` -- `HackerApplication` - -`sponsor.py`: -- `SponsorApplicationView` -- `SponsorDashboard` - -`mentor.py`: -- `ConvertHackerToMentor` - -`draft.py`: -- `save_draft` - -- [ ] **Step 1: Create `applications/views/` directory and sub-files** - -Follow the same pattern. `hacker.py` will contain most of the code. - -- [ ] **Step 2: Create `applications/views/__init__.py`** - -```python -from applications.views.hacker import ( - VIEW_APPLICATION_TYPE, - VIEW_APPLICATION_FORM_TYPE, - check_application_exists, - get_deadline, - user_is_in_blacklist, - ConfirmApplication, - CancelApplication, - HackerDashboard, - HackerApplication, -) -from applications.views.sponsor import SponsorApplicationView, SponsorDashboard -from applications.views.mentor import ConvertHackerToMentor -from applications.views.draft import save_draft - -__all__ = [ - 'VIEW_APPLICATION_TYPE', 'VIEW_APPLICATION_FORM_TYPE', - 'check_application_exists', 'get_deadline', 'user_is_in_blacklist', - 'ConfirmApplication', 'CancelApplication', 'HackerDashboard', 'HackerApplication', - 'SponsorApplicationView', 'SponsorDashboard', - 'ConvertHackerToMentor', - 'save_draft', -] -``` - -- [ ] **Step 3: Verify callers including the management command** - -```bash -grep -r "from applications.views" . --include="*.py" -grep -r "from applications import views" . --include="*.py" -python manage.py runserver -``` - -- [ ] **Step 4: Verify the management command imports cleanly** - -```bash -python manage.py expire_applications --help -``` - -Expected: prints the help text. If you get an ImportError, `VIEW_APPLICATION_TYPE` is not being re-exported correctly from `__init__.py`. - ---- - -### Task 10: Split `user/views.py` - -**Files:** -- Delete: `user/views.py` -- Create: `user/views/__init__.py` -- Create: `user/views/authentication.py` -- Create: `user/views/profile.py` - -**Important:** `user/auth.py` already exists as a CAS authentication backend. Do NOT name the views file `auth.py` — use `authentication.py`. - -**Class/function → file mapping:** - -`authentication.py`: -- `login`, `signup`, `Logout`, `activate` -- `password_reset`, `password_reset_confirm`, `password_reset_complete`, `password_reset_done` -- `verify_email_required`, `set_password`, `send_email_verification` -- `callback` -- `SponsorRegister` - -`profile.py`: -- `UserProfile` -- `DeleteAccount` - -- [ ] **Step 1: Create `user/views/` directory and sub-files** - -Follow the same pattern. - -- [ ] **Step 2: Create `user/views/__init__.py`** - -```python -from user.views.authentication import ( - login, signup, Logout, activate, - password_reset, password_reset_confirm, password_reset_complete, password_reset_done, - verify_email_required, set_password, send_email_verification, - callback, SponsorRegister, -) -from user.views.profile import UserProfile, DeleteAccount - -__all__ = [ - 'login', 'signup', 'Logout', 'activate', - 'password_reset', 'password_reset_confirm', 'password_reset_complete', 'password_reset_done', - 'verify_email_required', 'set_password', 'send_email_verification', - 'callback', 'SponsorRegister', - 'UserProfile', 'DeleteAccount', -] -``` - -- [ ] **Step 3: Verify callers and start dev server** - -```bash -grep -r "from user.views" . --include="*.py" -grep -r "from user import views" . --include="*.py" -python manage.py runserver -``` - ---- - -### Task 11: Split `baggage/views.py` - -**Files:** -- Delete: `baggage/views.py` -- Create: `baggage/views/__init__.py` -- Create: `baggage/views/volunteer.py` -- Create: `baggage/views/hacker.py` - -**Class/function → file mapping:** - -`volunteer.py`: -- `baggage_checkIn`, `baggage_checkOut` (admin operations) -- `organizer_tabs` -- `BaggageList`, `BaggageHacker`, `BaggageUsers` -- `BaggageAdd`, `BaggageDetail`, `BaggageMap`, `BaggageHistory` -- `BaggageAPI` - -`hacker.py`: -- `hacker_tabs` -- `BaggageCurrentHacker` - -- [ ] **Step 1: Create sub-files and `__init__.py`, delete original** - -Follow the same pattern as Task 7. - -- [ ] **Step 2: Create `baggage/views/__init__.py`** - -```python -from baggage.views.volunteer import ( - baggage_checkIn, - baggage_checkOut, - organizer_tabs, - BaggageList, - BaggageHacker, - BaggageUsers, - BaggageAdd, - BaggageDetail, - BaggageMap, - BaggageHistory, - BaggageAPI, -) -from baggage.views.hacker import hacker_tabs, BaggageCurrentHacker - -__all__ = [ - 'baggage_checkIn', 'baggage_checkOut', 'organizer_tabs', - 'BaggageList', 'BaggageHacker', 'BaggageUsers', - 'BaggageAdd', 'BaggageDetail', 'BaggageMap', 'BaggageHistory', - 'BaggageAPI', - 'hacker_tabs', 'BaggageCurrentHacker', -] -``` - -- [ ] **Step 3: Verify callers and start dev server** - -```bash -grep -r "from baggage.views" . --include="*.py" -grep -r "from baggage import views" . --include="*.py" -python manage.py runserver -``` - ---- - -### Task 12: Split `hardware/views.py` - -**Files:** -- Delete: `hardware/views.py` -- Create: `hardware/views/__init__.py` -- Create: `hardware/views/hacker.py` -- Create: `hardware/views/admin.py` - -**Class/function → file mapping:** - -`hacker.py`: -- `HardwareBorrowingsView` -- `HardwareListView` - -`admin.py`: -- `hardware_tabs` -- `HardwareAdminRequestsView` -- `HardwareAdminView` - -- [ ] **Step 1: Create sub-files and `__init__.py`, delete original** - -Follow the same pattern as Task 7. - -- [ ] **Step 2: Create `hardware/views/__init__.py`** - -```python -from hardware.views.hacker import HardwareBorrowingsView, HardwareListView -from hardware.views.admin import hardware_tabs, HardwareAdminRequestsView, HardwareAdminView - -__all__ = [ - 'HardwareBorrowingsView', 'HardwareListView', - 'hardware_tabs', 'HardwareAdminRequestsView', 'HardwareAdminView', -] -``` - -- [ ] **Step 3: Verify callers and start dev server** - -```bash -grep -r "from hardware.views" . --include="*.py" -grep -r "from hardware import views" . --include="*.py" -python manage.py runserver -``` - -- [ ] **Step 3: Final end-to-end check** - -With all views split, do a final check: - -```bash -python manage.py runserver -``` - -Navigate through the main flows in the browser: -- Hacker dashboard (`/`) -- Organizer application list (`/applications/`) -- Reimbursement dashboard (`/reimbursement/`) -- Check-in interface (`/checkin/`) -- Stats dashboard (`/stats/`) - -All should load without errors. diff --git a/docs/superpowers/specs/2026-04-02-codebase-refactor-design.md b/docs/superpowers/specs/2026-04-02-codebase-refactor-design.md deleted file mode 100644 index dbdda8f68..000000000 --- a/docs/superpowers/specs/2026-04-02-codebase-refactor-design.md +++ /dev/null @@ -1,192 +0,0 @@ -# Codebase Refactor Design — myhackupc -**Date:** 2026-04-02 -**Author:** Gerard Madrid -**Status:** Approved - ---- - -## Goal - -Make the myhackupc codebase maintainable for a volunteer dev team where members are new each year and unfamiliar with the project. The refactor moves things around and splits files — it does **not** improve or rewrite any logic. - ---- - -## Scope - -**Included apps:** `applications`, `organizers`, `reimbursement`, `user`, `checkin`, `baggage`, `hardware`, `meals`, `teams`, `stats`, `app` (core) - -**Excluded apps (stable, left untouched):** `judging`, `offer`, `discord` - ---- - -## Part 1: Views Splitting - -### Principle -Any `views.py` with multiple unrelated concerns becomes a `views/` package. The `views/__init__.py` re-exports **every name** that any other module in the project imports from that views module — not just names referenced in `urls.py`. This is the primary safety guarantee. - -Note: Django's URL files typically do `from app import views` and then call `views.SomeClass.as_view()`. As long as `__init__.py` exposes `SomeClass`, Django's URL machinery is unaffected regardless of which sub-file defines it. - -### `organizers/views.py` (1,136 lines) → `organizers/views/` package - -**Split by class/function (explicit assignments):** - -| Name | Target file | Notes | -|---|---|---| -| All `*ListView` classes (hacker, volunteer, mentor, sponsor, dubious, blacklist, waitlist) | `lists.py` | | -| `_OtherApplicationsListView` | `lists.py` | Private base class for volunteer/sponsor/mentor lists; must be in same file as its subclasses | -| `ApplicationDetailView` | `review.py` | Base class — must be in same file as `ReviewApplicationView` which inherits from it | -| `ReviewApplicationView` | `review.py` | | -| Vote and comment helper functions | `review.py` | | -| `InviteTeamListView` | `batch_ops.py` | It is a list view by name but contains the core batch invite algorithm — belongs here | -| Batch email sending orchestration | `batch_ops.py` | Note: the actual `send_batch_emails` function lives in `applications/emails.py`; organizers only has the orchestration | - -**`__init__.py` must re-export these specific names** (cross-app imports confirmed in codebase): -- All public view classes (for `urls.py`) -- `_OtherApplicationsListView` — imported by `applications/views.py` -- `hacker_tabs`, `volunteer_tabs`, `mentor_tabs`, `sponsor_tabs` — imported by `checkin/views.py` and `reimbursement/views.py` - -### `applications/views.py` (500 lines) → `applications/views/` package - -| File | Contents | -|---|---| -| `hacker.py` | Hacker application create/edit/cancel/confirm; shared helpers (`VIEW_APPLICATION_TYPE`, `check_application_exists`, etc.) | -| `mentor.py` | `ConvertHackerToMentor` | -| `sponsor.py` | `SponsorApplicationView`, `SponsorDashboard` | -| `draft.py` | Draft auto-save logic | -| `__init__.py` | Re-exports all view classes and constants | - -Note: there are no volunteer-specific view classes in `applications/views.py` — volunteers use the hacker application flow with role detection. No `volunteer.py` is created. - -**`__init__.py` must re-export:** -- All public view classes (for `urls.py`) -- `VIEW_APPLICATION_TYPE` — imported by `applications/management/commands/expire_applications.py` -- `VIEW_APPLICATION_FORM_TYPE` — companion constant in the same file; re-export for safety - -### Other active apps → `views/` packages - -| App | First file | Second file | -|---|---|---| -| `reimbursement` | `hacker.py` — request and receipt upload flow | `organizer.py` — approval and bulk ops. Note: `ReceiptReview` inherits from `ReimbursementDetail`; both must land in the same file (`organizer.py`) | -| `user` | `auth.py` — login, signup, password reset, email verification | `profile.py` — profile editing, account management. Note: `user/auth.py` already exists as a CAS authentication backend — the new views file must be named differently, e.g. `authentication.py` | -| `baggage` | `hacker.py` — hacker-facing baggage flow | `volunteer.py` — volunteer management interface | -| `hardware` | `hacker.py` — hacker request flow | `admin.py` — inventory management | - -### Left as single files (small or single concern) -- `checkin/views.py` (263 lines) -- `meals/views.py` (245 lines) -- `teams/views.py` (74 lines) -- `stats/views.py` (381 lines — large but single concern: analytics) - ---- - -## Part 2: CSS Reorganization - -### Principle -Styles for a feature live next to that feature's code. If a style only applies to one app, it belongs in that app's `static/css/` folder. - -### `custom-bootstrap.css` (1,496 lines) → `bootstrap-overrides/` component files - -Split into the following files under `app/static/css/bootstrap-overrides/`, **in this order** to preserve cascade: - -| File | Contents | -|---|---| -| `variables.css` | Colors, fonts, spacing resets | -| `print.css` | `@media print` rules | -| `glyphicons.css` | Glyphicon icon font | -| `scaffolding.css` | `.container`, `.container-fluid`, row/col base | -| `typography.css` | Headings, paragraphs, links, blockquote, code | -| `grid.css` | `.col-xs-*`, `.col-sm-*`, `.col-md-*`, `.col-lg-*` | -| `tables.css` | Table overrides | -| `forms.css` | Input, select, textarea overrides | -| `buttons.css` | Button styles and variants | -| `nav.css` | Navbar, tabs, breadcrumbs, pagination | -| `dropdowns.css` | Dropdown menus | -| `alerts.css` | Alert and badge styles | -| `modals.css` | Modal dialogs | -| `misc.css` | Everything else (panels, wells, progress bars, etc.) | - -`base.html` gets one `` tag per file, **in the same order as the original file sections**, preserving cascade order. - -### `main.css` (506 lines) -Audit for app-specific selectors. Any styles scoped to a specific feature are extracted to that app's `static/css/` file. What remains in `main.css` is global layout, typography baseline, theme colors, navbar, footer only. - -### App-specific CSS — existing files (correct, no change needed) -- `baggage/static/css/baggage.css` -- `hardware/static/css/hw.css` -- `applications/static/css/profile.css` — **cross-app note:** this file is also loaded by organizers and reimbursement templates. It is a shared stylesheet, not applications-only. Do not move it into the applications app folder; leave it where it is. -- `checkin/static/css/checkin.css` — already exists and already loaded by checkin templates - -### App-specific CSS — may need files created -If `main.css` contains styles for these apps, extract them: -- `reimbursement/static/css/reimbursement.css` -- `organizers/static/css/organizers.css` - -### Adding CSS to a template -Templates that extend `base_tabs.html` use `{% block extra_head %}` to add per-page stylesheets. -Templates that extend `base.html` directly use `{% block head %}`. -Do **not** use `{% block extra_css %}` — that block does not exist in this codebase. - ---- - -## Part 3: Documentation - -New `docs/` folder at the repo root. The existing README gets a note pointing to both `docs/getting-started.md` and `docs/setting-up.md`. - -### `docs/getting-started.md` -- What myhackupc is and who uses it (organizers, hackers, volunteers, mentors) -- Active Django apps: one sentence per app explaining its purpose -- Key config file: `app/hackathon_variables.py` — deadlines, flags, reimbursement limits, touched every year -- Where templates live (per-app `templates/` folders) -- Where styles live (per-app `static/css/` + global `app/static/css/`) -- URL-to-app mapping table - -### `docs/setting-up.md` -- Prerequisites, virtualenv, dependencies -- Environment variables and `hackathon_variables.py` setup -- Running the dev server, creating a superuser -- How to verify your changes work -*(Content extracted from README; README updated to point here)* - -### `docs/contributing.md` -Worked examples covering the most common yearly changes: -- **Adding/removing a field from the hacker application** — which files to touch: model (`applications/models/hacker.py`), form (`applications/forms/hacker.py`), migration, template -- **Adding/removing a field from volunteer or mentor application** — same pattern, different files -- **Changing the travel reimbursement cap** — just `app/hackathon_variables.py` - -### `docs/email-templates.md` -- Table of every email the system sends: email name, which app sends it, template path -- How to edit email content: edit `*_message.html` -- How to edit the subject line: edit `*_subject.txt` -- How to add a new variable to an email: add it to the context dict in the app's `emails.py`, then use `{{ variable_name }}` in the template - ---- - -## Safety Constraints - -1. No logic changes — only move code between files, never rewrite it -2. `views/__init__.py` must re-export every name imported from that views module by **any** file in the project — not just `urls.py`. Verify with a project-wide grep after each split. -3. CSS cascade order preserved — `bootstrap-overrides/` files loaded in `base.html` in the same order as sections appeared in the original file -4. No new dependencies introduced -5. All migrations left untouched -6. Excluded apps (`judging`, `offer`, `discord`) are not modified - ---- - -## Execution Order - -1. Documentation (`docs/` folder) — zero risk, pure addition -2. CSS reorganization — no logic, visual regression check -3. Views splitting — one app at a time, verify after each: - - Run the dev server and confirm it starts cleanly - - For `applications`: also run `python manage.py expire_applications --help` to confirm the management command still imports cleanly (Django dev server does not import management commands on startup, so a broken command will not be caught by a server start check alone) - - Do a project-wide grep for imports of the converted module to confirm nothing was missed - ---- - -## Out of Scope - -- Upgrading Django (currently 3.2 LTS) -- Rewriting any view, model, or form logic -- Adding new features -- Changing URL structure -- Improving test coverage From a0c0b4489cb5835508daa1dc23bc99a2d45340b5 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Fri, 3 Apr 2026 10:01:02 +0200 Subject: [PATCH 3/5] fix: cleanup imports --- organizers/views/lists.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/organizers/views/lists.py b/organizers/views/lists.py index 563636b7c..8073edb3c 100644 --- a/organizers/views/lists.py +++ b/organizers/views/lists.py @@ -1,28 +1,16 @@ -# Create your views here. -import os -from io import BytesIO -from zipfile import ZipFile - from django.conf import settings from django.contrib import messages from django.core.exceptions import ValidationError -from django.db import IntegrityError -from django.db.models import Count, Avg, F, Q, CharField -from django.db.models.functions import Concat -from django.http import Http404, HttpResponseRedirect, HttpResponse -from django.shortcuts import redirect +from django.http import HttpResponseRedirect, HttpResponse from django.urls import reverse from django.views import View -from django.views.generic import TemplateView from django_filters.views import FilterView from django_tables2 import SingleTableMixin from django_tables2.export import ExportMixin from django.utils import timezone from datetime import timedelta -from app import slack from app.mixins import TabsViewMixin -from app.slack import SlackInvitationException from applications import emails from applications.emails import send_batch_emails from applications.models import ( @@ -32,8 +20,6 @@ APP_INVITED, APP_LAST_REMIDER, APP_CONFIRMED, - AcceptedResume, - APP_ATTENDED, APP_REJECTED, ) from organizers import models @@ -41,7 +27,6 @@ ApplicationsListTable, ApplicationFilter, AdminApplicationsListTable, - AdminTeamListTable, InviteFilter, DubiousListTable, DubiousApplicationFilter, @@ -56,7 +41,6 @@ BlacklistListTable, BlacklistApplicationFilter, ) -from teams.models import Team from user.mixins import ( IsOrganizerMixin, IsDirectorMixin, From e6cb81b5dd24bd6a2a883970e041310ac644d3d6 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Fri, 3 Apr 2026 10:06:55 +0200 Subject: [PATCH 4/5] remove ai shit --- .claude/settings.json | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 7bf07a44a..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(wc -l /Users/gerardmadridmiro/self/hackupc/myhackupc/app/static/css/*.css)", - "Bash(wc -l /Users/gerardmadridmiro/self/hackupc/myhackupc/applications/models/*.py)", - "Bash(ls /Users/gerardmadridmiro/self/hackupc/myhackupc/app/hackathon_variables*)", - "Read(//Users/gerardmadridmiro/self/hackupc/myhackupc/**)", - "Read(//Users/gerardmadridmiro/self/hackupc/myhackupc/=== hardware_user_email.html veil/**)", - "Bash(python manage.py check)", - "Bash(python3 manage.py check)", - "Bash(env/bin/python manage.py check)", - "Bash(ls /Users/gerardmadridmiro/self/hackupc/myhackupc/baggage/views*)", - "Bash(ls /Users/gerardmadridmiro/self/hackupc/myhackupc/hardware/views*)" - ] - } -} From 9075bcd9d6c06449c75e8bfcf59a44ee610d2c23 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 13 Apr 2026 22:36:10 +0200 Subject: [PATCH 5/5] feat: add simple tests to codebase --- .circleci/config.yml | 2 +- discord/tests.py | 0 docs/testing.md | 226 ++++++++++++++++++++ hardware/tests.py | 3 - meals/tests.py | 0 pytest.ini | 7 + requirements.txt | 6 + setup.cfg | 6 + tests/__init__.py | 1 + tests/conftest.py | 83 +++++++ tests/factories.py | 150 +++++++++++++ baggage/tests.py => tests/flows/__init__.py | 0 tests/flows/test_hacker.py | 105 +++++++++ tests/flows/test_mentor.py | 72 +++++++ tests/flows/test_sponsor.py | 50 +++++ tests/flows/test_volunteer.py | 70 ++++++ 16 files changed, 777 insertions(+), 4 deletions(-) delete mode 100644 discord/tests.py create mode 100644 docs/testing.md delete mode 100644 hardware/tests.py delete mode 100644 meals/tests.py create mode 100644 pytest.ini create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/factories.py rename baggage/tests.py => tests/flows/__init__.py (100%) create mode 100644 tests/flows/test_hacker.py create mode 100644 tests/flows/test_mentor.py create mode 100644 tests/flows/test_sponsor.py create mode 100644 tests/flows/test_volunteer.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 005a77356..0000a180f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,7 +31,7 @@ jobs: name: Running tests command: | . env/bin/activate - python manage.py test + pytest --cov - run: name: Linting code command: | diff --git a/discord/tests.py b/discord/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 000000000..470dd9cf8 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,226 @@ +# Testing + +This project uses [pytest](https://pytest.org) with [pytest-django](https://pytest-django.readthedocs.io) and [factory-boy](https://factoryboy.readthedocs.io) for automated testing. Tests live in `tests/` and cover the four main application flows: hacker, volunteer, mentor, and sponsor. + +--- + +## Running the tests + +```bash +# Run all tests +pytest + +# With coverage report +pytest --cov + +# Run a single file +pytest tests/flows/test_hacker.py + +# Run a single test +pytest tests/flows/test_hacker.py::test_hacker_can_submit_application -v +``` + +Coverage is configured in `setup.cfg`. The report will fail if coverage across `applications`, `organizers`, and `user` drops below 60%. + +--- + +## Structure + +``` +tests/ +├── conftest.py # Shared fixtures (users, authenticated clients) +├── factories.py # factory-boy factories for creating test data +└── flows/ + ├── test_hacker.py # 8 tests covering the hacker application flow + ├── test_volunteer.py # 5 tests covering the volunteer application flow + ├── test_mentor.py # 5 tests covering the mentor application flow + └── test_sponsor.py # 3 tests covering the sponsor application flow +``` + +--- + +## How it works + +### Fixtures (`conftest.py`) + +`conftest.py` defines shared pytest fixtures available to every test file. + +`**use_locmem_email_backend` (autouse)** — runs automatically for every test. It overrides two Django settings that would otherwise break tests: + +- `EMAIL_BACKEND`: swaps SendGrid for Django's in-memory backend so views that send confirmation emails don't fail. +- `STATICFILES_STORAGE`: swaps whitenoise's manifest storage (which requires `collectstatic` to have been run) for a simple one that works without it. + +**User fixtures** — each creates a database user of the right type: + +```python +hacker_user # type=USR_HACKER +organizer_user # type=USR_ORGANIZER +volunteer_user # type=USR_VOLUNTEER +mentor_user # type=USR_MENTOR +sponsor_user # type=USR_SPONSOR +director_user # type=USR_ORGANIZER + is_director=True +``` + +**Client fixtures** — each returns `(client, user)` where the client is already logged in as that user: + +```python +hacker_client, organizer_client, volunteer_client, +mentor_client, sponsor_client, director_client +``` + +Use the tuple unpacking pattern in tests: + +```python +def test_something(hacker_client): + client, user = hacker_client + response = client.get(reverse("dashboard")) +``` + +### Factories (`factories.py`) + +Factories create realistic model instances without hitting external services. They use `factory.Sequence` for unique fields and `factory.Faker` for realistic fake data. + +**Important:** `UserFactory._create()` calls `user.set_password()` before saving. This is required because view mixins (`IsHackerMixin`, `DashboardMixin`, etc.) call `has_usable_password()` and redirect to the password-change page if it returns `False`. Django's default `create()` does not call `set_password()`, so the override is necessary. + + +| Factory | Model | Default status | +| ----------------------------- | ---------------------- | ------------------------------------ | +| `UserFactory` | `User` | — | +| `OrganizerUserFactory` | `User` | type=USR_ORGANIZER | +| `DirectorUserFactory` | `User` | type=USR_ORGANIZER, is_director=True | +| `HackerApplicationFactory` | `HackerApplication` | APP_PENDING | +| `VolunteerApplicationFactory` | `VolunteerApplication` | APP_PENDING | +| `MentorApplicationFactory` | `MentorApplication` | APP_PENDING | +| `SponsorApplicationFactory` | `SponsorApplication` | APP_CONFIRMED | + + +Override any field when creating an instance: + +```python +app = HackerApplicationFactory(user=user, status=APP_INVITED) +``` + +### Tests (`flows/`) + +Each test file covers one applicant type. Tests use `@pytest.mark.django_db` to get database access per test. The pattern is: + +1. Set up data (via fixtures or factories) +2. Make an HTTP request via `client.get()` or `client.post()` +3. Assert the response status code and the resulting database state + +--- + +## Key points to know + +### `origin` must match `cities.json` + +The `origin` field on application forms is validated against a list of cities. It must be in the format `"City, Province, Country"`: + +```python +"origin": "Barcelona, Barcelona, Spain" # correct +"origin": "Barcelona" # fails validation +``` + +### Cancel requires `APP_INVITED`, not `APP_PENDING` + +`BaseApplication.can_be_cancelled()` only returns `True` for `APP_INVITED`, `APP_CONFIRMED`, and `APP_LAST_REMINDER`. Testing cancellation with a PENDING application will fail silently (the view will redirect but the status won't change): + +```python +app = HackerApplicationFactory(user=user, status=APP_INVITED) # correct +app = HackerApplicationFactory(user=user, status=APP_PENDING) # can't be cancelled +``` + +### `ConfirmApplication` is GET-only + +The confirm view (`/application//confirm/`) uses `client.get()`, not `client.post()`. Confirming a PENDING application raises a `ValidationError` inside the model, which the view catches and converts to a 404. + +### Organizer vote uses integer PK, not UUID + +`ReviewApplicationView.post()` looks up the application with `HackerApplication.objects.get(pk=request.POST.get("app_id"))`. Pass the integer primary key as a string: + +```python +data={"app_id": str(app.pk), ...} # correct +data={"app_id": str(app.uuid), ...} # wrong — lookup will fail +``` + +### Mentor and sponsor lists require `is_director=True` + +`HaveMentorPermissionMixin` and `HaveSponsorPermissionMixin` require either a specific permission or `is_director=True`. A plain `OrganizerUserFactory` user will get a 302 redirect. Use `director_client`: + +```python +def test_organizer_can_view_mentor_list(director_client): # correct +def test_organizer_can_view_mentor_list(organizer_client): # 302, not 200 +``` + +### Sponsor submission uses a token URL, not the dashboard + +Sponsors apply via a unique invite URL (`/sponsor///`), not by logging in. The token comes from the `user.models.Token` model (not Django's password reset). Test it by constructing the URL directly: + +```python +token_obj = Token.objects.create(user=sponsor_user) +uid = urlsafe_base64_encode(force_bytes(sponsor_user.pk)) +url = f"/sponsor/{uid}/{token_obj.uuid_str()}/" +client.post(url, data=VALID_SPONSOR_FORM) +``` + +The view renders `sponsor_submitted.html` on success (status 200), not a redirect. + +--- + +## Adding a new test + +### Adding a test to an existing file + +Open the relevant file in `tests/flows/` and add a function: + +```python +@pytest.mark.django_db +def test_hacker_cannot_edit_after_review(hacker_client): + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("application")) + # invited hackers should not see the edit form + assert response.status_code == 302 +``` + +Use `@pytest.mark.django_db` on every test that touches the database. Use the fixtures from `conftest.py` as parameters — pytest injects them automatically. + +### Adding a test for a new applicant type + +1. Add a `UserFactory` subclass in `tests/factories.py` with the correct `type` value. +2. Add an `ApplicationFactory` subclass with all required fields (run the form in a browser or read the model to find required fields). +3. Add user and client fixtures to `tests/conftest.py` following the existing pattern. +4. Create `tests/flows/test_.py` and write your tests. + +### Adding a factory for a new model + +```python +class MyModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = MyModel + + # Use factory.Sequence for fields that must be unique + name = factory.Sequence(lambda n: f"Name {n}") + + # Use factory.Faker for realistic fake data + description = factory.Faker("text", max_nb_chars=200) + + # Use factory.SubFactory to link related models + user = factory.SubFactory(UserFactory) + + # Hard-code constants where variation isn't needed + status = APP_PENDING +``` + +--- + +## CI + +Tests run automatically on CircleCI on every push. The CI config is at `.circleci/config.yml`. It runs: + +```bash +pytest --cov # runs tests and generates coverage +flake8 # lints the codebase +``` + +Both must pass for a build to go green. \ No newline at end of file diff --git a/hardware/tests.py b/hardware/tests.py deleted file mode 100644 index 2cef6ba97..000000000 --- a/hardware/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# TODO -# from django.test import TestCase -# Create your tests here. diff --git a/meals/tests.py b/meals/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..88e7640ea --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +DJANGO_SETTINGS_MODULE = app.settings +testpaths = tests +python_files = test_*.py +filterwarnings = + ignore::django.utils.deprecation.RemovedInDjango40Warning + ignore:Use '__' to separate path components:DeprecationWarning diff --git a/requirements.txt b/requirements.txt index 43d673b1b..d946bec2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,3 +58,9 @@ whitenoise==5.3.0 xlrd==1.2.0 xlwt==1.3.0 slack-sdk==3.15.2 +pytest==7.4.3 +pytest-django==4.7.0 +factory-boy==3.3.0 +faker==20.1.0 +coverage==7.3.2 +pytest-cov==4.1.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..000f3e702 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[coverage:run] +source = applications,organizers,user +omit = */migrations/*, */tests/* + +[coverage:report] +fail_under = 60 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e878bfc80 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Don't delete this file, pytest needs it to find the source of tests hehe \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..1aad7e3ec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,83 @@ +import pytest + +from tests.factories import ( + DirectorUserFactory, + MentorUserFactory, + OrganizerUserFactory, + SponsorUserFactory, + UserFactory, + VolunteerUserFactory, +) + + +@pytest.fixture(autouse=True) +def use_locmem_email_backend(settings): + """Override email backend so confirm views don't attempt to hit SendGrid.""" + settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + settings.STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" + + +@pytest.fixture +def hacker_user(db): + return UserFactory() + + +@pytest.fixture +def organizer_user(db): + return OrganizerUserFactory() + + +@pytest.fixture +def volunteer_user(db): + return VolunteerUserFactory() + + +@pytest.fixture +def mentor_user(db): + return MentorUserFactory() + + +@pytest.fixture +def sponsor_user(db): + return SponsorUserFactory() + + +@pytest.fixture +def hacker_client(client, hacker_user): + client.force_login(hacker_user) + return client, hacker_user + + +@pytest.fixture +def organizer_client(client, organizer_user): + client.force_login(organizer_user) + return client, organizer_user + + +@pytest.fixture +def volunteer_client(client, volunteer_user): + client.force_login(volunteer_user) + return client, volunteer_user + + +@pytest.fixture +def mentor_client(client, mentor_user): + client.force_login(mentor_user) + return client, mentor_user + + +@pytest.fixture +def sponsor_client(client, sponsor_user): + client.force_login(sponsor_user) + return client, sponsor_user + + +@pytest.fixture +def director_user(db): + return DirectorUserFactory() + + +@pytest.fixture +def director_client(client, director_user): + client.force_login(director_user) + return client, director_user diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 000000000..0249621b6 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,150 @@ +import factory +from django.contrib.auth import get_user_model + +from applications.models import APP_CONFIRMED, APP_PENDING +from applications.models.hacker import HackerApplication +from applications.models.mentor import MentorApplication +from applications.models.sponsor import SponsorApplication +from applications.models.volunteer import VolunteerApplication +from user.models import ( + USR_HACKER, + USR_MENTOR, + USR_ORGANIZER, + USR_SPONSOR, + USR_VOLUNTEER, +) + +User = get_user_model() + + +class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = User + + email = factory.Sequence(lambda n: f"hacker{n}@example.com") + name = factory.Faker("name") + type = USR_HACKER + email_verified = True + is_active = True + + @classmethod + def _create(cls, model_class, *args, **kwargs): + # set_password() is required — views check has_usable_password() + user = model_class(*args, **kwargs) + user.set_password("testpass123") + user.save() + return user + + +class OrganizerUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"organizer{n}@example.com") + type = USR_ORGANIZER + + +class DirectorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"director{n}@example.com") + type = USR_ORGANIZER + is_director = True + + +class VolunteerUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"volunteer{n}@example.com") + type = USR_VOLUNTEER + + +class MentorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"mentor{n}@example.com") + type = USR_MENTOR + + +class SponsorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"sponsor{n}@example.com") + type = USR_SPONSOR + + +class HackerApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = HackerApplication + + user = factory.SubFactory(UserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + description = factory.Faker("text", max_nb_chars=200) + university = factory.Faker("company") + degree = "Computer Science" + kind_studies = "BACHELOR" + graduation_year = 2026 + tshirt_size = "M" + diet = "None" + phone_number = "+34600000000" + gender = "NA" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + online = False + + +class VolunteerApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = VolunteerApplication + + user = factory.SubFactory(VolunteerUserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + gender = "NA" + tshirt_size = "M" + diet = "None" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + studies_and_course = "Computer Science" + quality = "Teamwork" + weakness = "Perfectionism" + cool_skill = "Python" + volunteer_motivation = "I want to help hackers." + attendance = "1" + languages = "English" + night_shifts = "No" + first_time_volunteer = True + hear_about_us = "Posters" + + +class MentorApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = MentorApplication + + user = factory.SubFactory(MentorUserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + gender = "NA" + tshirt_size = "M" + diet = "None" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + english_level = 3 + attendance = "1" + online = False + fluent = "Python, JavaScript" + experience = "5 years of software development" + why_mentor = "I want to share my knowledge with students." + participated = "HackUPC 2023" + study_work = True + degree = "Computer Science" + graduation_year = 2026 + first_time_mentor = True + + +class SponsorApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = SponsorApplication + + user = factory.SubFactory(SponsorUserFactory) + status = APP_CONFIRMED # sponsors default to CONFIRMED, not PENDING + name = factory.Sequence(lambda n: f"Sponsor Corp {n}") + email = factory.Faker("email") + phone_number = "+34600000000" + tshirt_size = "M" + diet = "None" + position = "Engineer" + attendance = "1" diff --git a/baggage/tests.py b/tests/flows/__init__.py similarity index 100% rename from baggage/tests.py rename to tests/flows/__init__.py diff --git a/tests/flows/test_hacker.py b/tests/flows/test_hacker.py new file mode 100644 index 000000000..987ed21d4 --- /dev/null +++ b/tests/flows/test_hacker.py @@ -0,0 +1,105 @@ +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.hacker import HackerApplication +from organizers.models import Vote +from tests.factories import HackerApplicationFactory + +VALID_HACKER_FORM = { + "phone_number": "+34600000000", + "kind_studies": "BACHELOR", + "under_age": "False", + "terms_and_conditions": True, + "diet": "None", + "tshirt_size": "M", + "origin": "Barcelona, Barcelona, Spain", + "description": "I want to build things at a hackathon.", + "graduation_year": "2026", + "gender": "NA", + "first_timer": True, + "lennyface": "( ͡° ͜ʖ ͡°)", + "online": False, + "university": "Universitat Politècnica de Catalunya", + "degree": "Computer Science", + "discover": "3", +} + + +@pytest.mark.django_db +def test_unauthenticated_redirected_from_dashboard(client): + response = client.get(reverse("dashboard")) + assert response.status_code == 302 + assert "/user/login/" in response["Location"] + + +@pytest.mark.django_db +def test_hacker_can_view_dashboard(hacker_client): + client, user = hacker_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_hacker_can_submit_application(hacker_client): + client, user = hacker_client + resume = SimpleUploadedFile("cv.pdf", b"pdf content", content_type="application/pdf") + data = {**VALID_HACKER_FORM, "resume": resume} + response = client.post(reverse("dashboard"), data=data) + assert response.status_code == 302 + assert HackerApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_hacker_cannot_submit_duplicate(hacker_client): + client, user = hacker_client + HackerApplicationFactory(user=user) + resume = SimpleUploadedFile("cv.pdf", b"pdf content", content_type="application/pdf") + data = {**VALID_HACKER_FORM, "resume": resume} + client.post(reverse("dashboard"), data=data) + # OneToOneField constraint means there is always exactly one application per user + assert HackerApplication.objects.filter(user=user).count() == 1 + + +@pytest.mark.django_db +def test_hacker_can_cancel_invited(hacker_client): + # APP_PENDING cannot be cancelled — can_be_cancelled() requires INVITED/CONFIRMED/LAST_REMINDER + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_hacker_can_confirm(hacker_client): + # ConfirmApplication is GET-only + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_pending_hacker_cannot_confirm(hacker_client): + # confirm() raises ValidationError for PENDING status → view raises Http404 + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_PENDING) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_organizer_can_vote_on_application(organizer_client, db): + client, organizer = organizer_client + app = HackerApplicationFactory() + response = client.post( + reverse("review_detail", kwargs={"id": app.uuid_str}), + data={"app_id": str(app.pk), "tech_rat": "3", "pers_rat": "4"}, + ) + assert response.status_code == 302 + assert Vote.objects.filter(application=app, user=organizer).count() == 1 diff --git a/tests/flows/test_mentor.py b/tests/flows/test_mentor.py new file mode 100644 index 000000000..34b246bdb --- /dev/null +++ b/tests/flows/test_mentor.py @@ -0,0 +1,72 @@ +import pytest +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.mentor import MentorApplication +from tests.factories import MentorApplicationFactory + +VALID_MENTOR_FORM = { + "gender": "NA", + "under_age": "False", + "study_work": "True", + "english_level": "3", + "attendance": ["1"], + "tshirt_size": "M", + "diet": "None", + "origin": "Barcelona, Barcelona, Spain", + "linkedin": "https://www.linkedin.com/in/testmentor", + "fluent": "Python, JavaScript", + "experience": "5 years of software development.", + "why_mentor": "I want to share my knowledge with students.", + "participated": "HackUPC 2023", + "terms_and_conditions": True, + "degree": "Computer Science", + "graduation_year": "2026", + "first_timer": True, + "lennyface": "( ͡° ͜ʖ ͡°)", + "online": False, +} + + +@pytest.mark.django_db +def test_mentor_can_view_dashboard(mentor_client): + client, user = mentor_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_mentor_can_submit_application(mentor_client): + client, user = mentor_client + response = client.post(reverse("dashboard"), data=VALID_MENTOR_FORM) + if response.status_code != 302: + print(response.context['form'].errors) + assert response.status_code == 302 + assert MentorApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_mentor_can_cancel_invited(mentor_client): + client, user = mentor_client + app = MentorApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_mentor_can_confirm(mentor_client): + client, user = mentor_client + app = MentorApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_organizer_can_view_mentor_list(director_client): + client, _ = director_client + response = client.get(reverse("mentor_list")) + assert response.status_code == 200 diff --git a/tests/flows/test_sponsor.py b/tests/flows/test_sponsor.py new file mode 100644 index 000000000..6f3c8c396 --- /dev/null +++ b/tests/flows/test_sponsor.py @@ -0,0 +1,50 @@ +import pytest +from django.test import Client +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from applications.models.sponsor import SponsorApplication +from user.models import Token +from tests.factories import SponsorUserFactory + +VALID_SPONSOR_FORM = { + "name": "Jane Doe", + "email": "jane.doe@techcorp.com", + "attendance": ["1"], + "diet": "None", + "tshirt_size": "M", + "phone_number": "+34600000000", + "position": "Software Engineer", + "terms_and_conditions": True, +} + + +@pytest.mark.django_db +def test_sponsor_can_view_dashboard(sponsor_client): + client, user = sponsor_client + response = client.get(reverse("sponsor_dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_sponsor_can_submit_application(db): + sponsor_user = SponsorUserFactory() + token_obj = Token.objects.create(user=sponsor_user) + uid = urlsafe_base64_encode(force_bytes(sponsor_user.pk)) + token = token_obj.uuid_str() + url = f"/sponsor/{uid}/{token}/" + client = Client() + response = client.post(url, data=VALID_SPONSOR_FORM) + if response.status_code != 200: + print(response.context['form'].errors) + # View renders sponsor_submitted.html on success (200, not 302) + assert response.status_code == 200 + assert SponsorApplication.objects.count() == 1 + + +@pytest.mark.django_db +def test_organizer_can_view_sponsor_list(director_client): + client, _ = director_client + response = client.get(reverse("sponsor_list")) + assert response.status_code == 200 diff --git a/tests/flows/test_volunteer.py b/tests/flows/test_volunteer.py new file mode 100644 index 000000000..864926df7 --- /dev/null +++ b/tests/flows/test_volunteer.py @@ -0,0 +1,70 @@ +import pytest +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.volunteer import VolunteerApplication +from tests.factories import VolunteerApplicationFactory + +VALID_VOLUNTEER_FORM = { + "gender": "NA", + "under_age": "False", + "studies_and_course": "Computer Science", + "night_shifts": "No", + "first_time_volunteer": "True", + "diet": "None", + "tshirt_size": "M", + "origin": "Barcelona, Barcelona, Spain", + "hear_about_us": "Posters", + "terms_and_conditions": True, + "attendance": ["1"], + "languages": ["English"], + "quality": "Team player", + "weakness": "Perfectionist", + "cool_skill": "Python", + "volunteer_motivation": "I want to help hackers succeed.", + "graduation_year": "2026", +} + + +@pytest.mark.django_db +def test_volunteer_can_view_dashboard(volunteer_client): + client, user = volunteer_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_volunteer_can_submit_application(volunteer_client): + client, user = volunteer_client + response = client.post(reverse("dashboard"), data=VALID_VOLUNTEER_FORM) + if response.status_code != 302: + print(response.context['form'].errors) + assert response.status_code == 302 + assert VolunteerApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_volunteer_can_cancel_invited(volunteer_client): + client, user = volunteer_client + app = VolunteerApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_volunteer_can_confirm(volunteer_client): + client, user = volunteer_client + app = VolunteerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_organizer_can_view_volunteer_list(organizer_client): + client, _ = organizer_client + response = client.get(reverse("volunteer_list")) + assert response.status_code == 200