diff --git a/hknweb/candidate/static/candidate/style.css b/hknweb/candidate/static/candidate/style.css index 9352eceb..e651fe3f 100644 --- a/hknweb/candidate/static/candidate/style.css +++ b/hknweb/candidate/static/candidate/style.css @@ -1,8 +1,3 @@ -.content { - padding-top: 1em; - padding-bottom: 1em; -} - .centered { max-width: 1250px; margin: 0 auto; @@ -246,9 +241,10 @@ h2:first-child { visibility: visible; } -#confirm-text { - font-size: 1.4em; +.confirm-text { + font-size: 1.3em; margin-left: 10%; + margin-right: 10%; } #sunny { @@ -266,10 +262,3 @@ h2:first-child { text-align: right; float: right; } - -#hiding-cat { - width: 45%; - display: block; /* Center image */ - margin-left: auto; - margin-right: auto; -} diff --git a/hknweb/candidate/templates/candidate/review_confirm.html b/hknweb/candidate/templates/candidate/review_confirm.html index 4b2a2b73..38841bf1 100644 --- a/hknweb/candidate/templates/candidate/review_confirm.html +++ b/hknweb/candidate/templates/candidate/review_confirm.html @@ -14,10 +14,10 @@ {% block content %}
- +
You have successfully reviewed the challenge requested by {{ requester_name }}! - -

+
+
cute dog @@ -28,7 +28,7 @@

@@ -48,6 +48,7 @@
  • I dreamed of dating (the 20 year-old) him last week.
    There was a hot blond but I still chose my shy Jay.
  • --> +
    diff --git a/hknweb/candidate/views.py b/hknweb/candidate/views.py index 1470fbdb..3adc1f6d 100644 --- a/hknweb/candidate/views.py +++ b/hknweb/candidate/views.py @@ -2,41 +2,25 @@ from django.views.generic.edit import FormView from django.shortcuts import render, redirect, reverse from django.core.mail import EmailMultiAlternatives +from django.core.exceptions import PermissionDenied from django.template.loader import render_to_string from django.contrib import messages from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.utils.decorators import method_decorator from django.utils import timezone from django.conf import settings -from django.contrib.staticfiles.finders import find from django.db.models import Q from dal import autocomplete -from random import randint +from hknweb.utils import login_and_permission, method_login_and_permission, get_rand_photo from .models import OffChallenge, BitByteActivity, Announcement, CandidateForm from ..events.models import Event, Rsvp from .forms import ChallengeRequestForm, ChallengeConfirmationForm, BitByteRequestForm -# decorators - -# used for things only officers and candidates can access -# TODO: use permissions instead of just the groups -def check_account_access(func): - def check_then_call(request, *args, **kwargs): - if not is_cand_or_officer(request.user): - return render(request, "errors/401.html", status=401) - return func(request, *args, **kwargs) - return check_then_call - - # views -# Candidate portal home -@method_decorator(login_required(login_url='/accounts/login/'), name='dispatch') -@method_decorator(check_account_access, name='dispatch') -# @method_decorator(is_cand_or_officer) +@method_login_and_permission('candidate.view_announcement') class IndexView(generic.TemplateView): + """ Candidate portal home. """ template_name = 'candidate/index.html' context_object_name = 'my_favorite_publishers' @@ -90,11 +74,9 @@ def get_context_data(self): } return context -# Form for submitting officer challenge requests -# And list of past requests for candidate -@method_decorator(login_required(login_url='/accounts/login/'), name='dispatch') -@method_decorator(check_account_access, name='dispatch') +@method_login_and_permission('candidate.add_offchallenge') class CandRequestView(FormView, generic.ListView): + """ Form for submitting officer challenge requests and list of past requests for candidate. """ template_name = 'candidate/candreq.html' form_class = ChallengeRequestForm success_url = "/cand/candreq" @@ -139,12 +121,11 @@ def get_queryset(self): .order_by('-request_date') return result -# List of past challenge requests for officer -# Non-officers can still visit this page by typing in the url, -# but it will not have any new entries -@method_decorator(login_required(login_url='/accounts/login/'), name='dispatch') -@method_decorator(check_account_access, name='dispatch') +@method_login_and_permission('candidate.view_offchallenge') class OffRequestView(generic.ListView): + """ List of past challenge requests for officer. + Non-officers can still visit this page by typing in the url, + but it will not have any new entries. """ template_name = 'candidate/offreq.html' context_object_name = 'challenge_list' @@ -155,11 +136,10 @@ def get_queryset(self): .order_by('-request_date') return result -# List of past bit-byte activities for candidates -# Offices can still visit this page but it will not have any new entries -@method_decorator(login_required(login_url='/accounts/login/'), name='dispatch') -@method_decorator(check_account_access, name='dispatch') +@method_login_and_permission('candidate.add_bitbyteactivity') class BitByteView(FormView, generic.ListView): + """ Form for submitting bit-byte activity requests and list of past requests for candidate. + Officers can still visit this page, but it will not have any new entries. """ template_name = 'candidate/bitbyte.html' form_class = BitByteRequestForm success_url = "/cand/bitbyte" @@ -203,15 +183,13 @@ def get_queryset(self): .order_by('-request_date') return result -# Officer views and confirms a challenge request after clicking email link -# Only the officer who game the challenge can review it -@login_required(login_url='/accounts/login/') -@check_account_access +@login_and_permission('candidate.change_offchallenge') def officer_confirm_view(request, pk): - # TODO: gracefully handle when a challenge does not exist + """ Officer views and confirms a challenge request after clicking email link. + Only the officer who gave the challenge can review it. """ challenge = OffChallenge.objects.get(id=pk) if request.user.id != challenge.officer.id: - return render(request, "errors/401.html", status=401) + raise PermissionDenied # not the officer that gave the challenge requester_name = challenge.requester.get_full_name() form = ChallengeConfirmationForm(request.POST or None, instance=challenge) @@ -235,11 +213,9 @@ def officer_confirm_view(request, pk): return redirect('/cand/reviewconfirm/{}'.format(pk)) return render(request, "candidate/challenge_confirm.html", context=context) - -# The page displayed after officer reviews challenge and clicks "submit" -@login_required(login_url='/accounts/login/') -@check_account_access +@login_and_permission('candidate.view_offchallenge') def officer_review_confirmation(request, pk): + """ The page displayed after officer reviews challenge and clicks "submit." """ challenge = OffChallenge.objects.get(id=pk) requester_name = challenge.requester.get_full_name() context = { @@ -248,11 +224,9 @@ def officer_review_confirmation(request, pk): } return render(request, "candidate/review_confirm.html", context=context) - -# Detail view of an officer challenge -@login_required(login_url='/accounts/login/') -@check_account_access +@login_and_permission('candidate.view_offchallenge') def challenge_detail_view(request, pk): + """ Detail view of an officer challenge. """ challenge = OffChallenge.objects.get(id=pk) officer_name = challenge.officer.get_full_name() requester_name = challenge.requester.get_full_name() @@ -275,45 +249,31 @@ def challenge_detail_view(request, pk): } return render(request, "candidate/challenge_detail.html", context=context) - -# HELPERS - +# this is needed otherwise anyone can see the users in the database +@method_login_and_permission('auth.view_user') class OfficerAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): - if not self.request.user.is_authenticated: - return User.objects.none() - qs = User.objects.filter(groups__name=settings.OFFICER_GROUP) if self.q: - qs = qs.filter(Q(username__icontains=self.q) | Q(first_name__icontains=self.q) | Q(last_name__icontains=self.q)) + qs = qs.filter( + Q(username__icontains=self.q) | + Q(first_name__icontains=self.q) | + Q(last_name__icontains=self.q)) return qs +# this is needed otherwise anyone can see the users in the database +@method_login_and_permission('auth.view_user') class UserAutocomplete(autocomplete.Select2QuerySetView): def get_queryset(self): - if not self.request.user.is_authenticated: - return User.objects.none() - qs = User.objects.all() if self.q: qs = qs.filter( - Q(username__icontains=self.q) | Q(first_name__icontains=self.q) | Q(last_name__icontains=self.q)) + Q(username__icontains=self.q) | + Q(first_name__icontains=self.q) | + Q(last_name__icontains=self.q)) return qs -def is_cand_or_officer(user): - return user.groups.filter(name=settings.CAND_GROUP).exists() or \ - user.groups.filter(name=settings.OFFICER_GROUP).exists() - -# This function is not used; it can be used to view all photos available -def get_all_photos(): - with open(find("candidate/animal_photo_urls.txt")) as f: - urls = f.readlines() - return [url.strip() + "?w=400" for url in urls] - -# images from pexels.com -def get_rand_photo(width=400): - with open(find("candidate/animal_photo_urls.txt")) as f: - urls = f.readlines() - return urls[randint(0, len(urls) - 1)].strip() + "?w=" + str(width) +# HELPERS def send_challenge_confirm_email(request, challenge, confirmed): subject = '[HKN] Your officer challenge was reviewed' @@ -359,8 +319,8 @@ def send_bitbyte_confirm_email(request, bitbyte, confirmed): msg.send() -# what the event types are called on admin site -# code will not work if they're called something else!! +""" What the event types are called on admin site. + Code will not work if they're called something else!! """ map_event_vars = { settings.MANDATORY_EVENT: 'Mandatory', settings.FUN_EVENT: 'Fun', @@ -370,9 +330,9 @@ def send_bitbyte_confirm_email(request, bitbyte, confirmed): settings.HANGOUT_EVENT: 'Hangout', } -# Takes in all confirmed rsvps and sorts them into types, current hard coded # TODO: support more flexible typing and string-to-var parsing/conversion def sort_rsvps_into_events(rsvps): + """ Takes in all confirmed rsvps and sorts them into types, currently hard coded. """ # Events in admin are currently in a readable format, must convert them to callable keys for Django template sorted_events = dict.fromkeys(map_event_vars.keys()) for event_key, event_type in map_event_vars.items(): @@ -394,8 +354,8 @@ def sort_rsvps_into_events(rsvps): settings.BITBYTE_ACTIVITY: 3, } -# Checks which requirements have been fulfilled by a candidate def check_requirements(sorted_rsvps, num_challenges, num_bitbytes): + """ Checks which requirements have been fulfilled by a candidate. """ req_statuses = dict.fromkeys(req_list.keys(), False) for req_type, minimum in req_list.items(): if req_type == settings.BITBYTE_ACTIVITY: @@ -409,6 +369,6 @@ def check_requirements(sorted_rsvps, num_challenges, num_bitbytes): req_statuses[req_type] = True return req_statuses -# returns whether officer interactivities are satisfied def check_interactivity_requirements(hangouts, challenges): + """ Returns whether officer interactivities are satisfied. """ return hangouts >= 1 and challenges >= 1 and hangouts + challenges >= 3 diff --git a/hknweb/events/models.py b/hknweb/events/models.py index d73736a8..a86b9386 100644 --- a/hknweb/events/models.py +++ b/hknweb/events/models.py @@ -49,7 +49,8 @@ def waitlist_set(self): if not self.rsvp_limit: return self.rsvp_set.none() return self.rsvp_set.order_by("created_at")[self.rsvp_limit:] - + + class Rsvp(models.Model): user = models.ForeignKey(User, models.CASCADE, verbose_name="rsvp'd by") event = models.ForeignKey(Event, models.CASCADE) diff --git a/hknweb/events/templates/events/index.html b/hknweb/events/templates/events/index.html index deb45b6a..8b6a0709 100644 --- a/hknweb/events/templates/events/index.html +++ b/hknweb/events/templates/events/index.html @@ -86,7 +86,7 @@

    +
    {% if perms.events.add_event %}
    diff --git a/hknweb/events/views.py b/hknweb/events/views.py index 64f284b0..e28763e0 100644 --- a/hknweb/events/views.py +++ b/hknweb/events/views.py @@ -2,30 +2,12 @@ from django.http import Http404 from django.contrib import messages from django.shortcuts import get_object_or_404 -from django.conf import settings -from django.contrib.auth.decorators import login_required, permission_required -from django.utils.decorators import method_decorator from django.views import generic +from hknweb.utils import login_and_permission, method_login_and_permission from .models import Event, EventType, Rsvp from .forms import EventForm -# decorators - -# used for things only officers and candidates can access -def check_account_access(func): - def check_then_call(request, *args, **kwargs): - if not is_cand_or_officer(request.user): - return render(request, 'errors/401.html', status=401) - return func(request, *args, **kwargs) - return check_then_call - - -def is_cand_or_officer(user): - return user.groups.filter(name=settings.CAND_GROUP).exists() or \ - user.groups.filter(name=settings.OFFICER_GROUP).exists() - - # views def index(request): @@ -38,23 +20,22 @@ def index(request): } return render(request, 'events/index.html', context) - -@login_required(login_url='/accounts/login/') -@check_account_access +@login_and_permission('events.view_event') def show_details(request, id): - event = get_object_or_404(Event, pk=id) rsvps = Rsvp.objects.filter(event=event) rsvpd = Rsvp.objects.filter(user=request.user, event=event).exists() waitlisted = False waitlist_position = 0 - if (rsvpd): + + if rsvpd: # Gets the rsvp object for the user rsvp = Rsvp.objects.filter(user=request.user, event=event)[:1].get() # Check if waitlisted if event.rsvp_limit: rsvps_before = rsvps.filter(created_at__lt = rsvp.created_at).count() waitlisted = rsvps_before >= event.rsvp_limit + # Get waitlist position if waitlisted: position = rsvps.filter(created_at__lt=rsvp.created_at).count() @@ -75,9 +56,7 @@ def show_details(request, id): } return render(request, 'events/show_details.html', context) - -@login_required(login_url='/accounts/login/') -@check_account_access +@login_and_permission('events.add_rsvp') def rsvp(request, id): if request.method != 'POST': raise Http404() @@ -90,9 +69,7 @@ def rsvp(request, id): messages.error(request, 'You have already RSVP\'d.') return redirect('/events/' + str(id)) - -@login_required(login_url='/accounts/login/') -@check_account_access +@login_and_permission('events.remove_rsvp') def unrsvp(request, id): if request.method != 'POST': raise Http404() @@ -105,8 +82,7 @@ def unrsvp(request, id): rsvp.delete() return redirect(event) - -@permission_required('events.add_event', login_url='/accounts/login/') +@login_and_permission('events.add_event') def add_event(request): form = EventForm(request.POST or None) if request.method == 'POST': @@ -122,7 +98,7 @@ def add_event(request): return render(request, 'events/event_add.html', {'form': EventForm(None)}) return render(request, 'events/event_add.html', {'form': EventForm(None)}) -@method_decorator(permission_required('events.change_event', login_url='/accounts/login/'), name='dispatch') +@method_login_and_permission('events.change_event') class EventUpdateView(generic.edit.UpdateView): model = Event fields = ['name', 'slug', 'start_time', 'end_time', 'location', 'event_type', diff --git a/hknweb/settings/dev.py b/hknweb/settings/dev.py index 948614a2..8f8acda6 100644 --- a/hknweb/settings/dev.py +++ b/hknweb/settings/dev.py @@ -1,6 +1,6 @@ from .common import * -#In dev mode, attempt to use real secrets, but if unavailiable, fall back to dummy secrets +# In dev mode, attempt to use real secrets, but if unavailiable, fall back to dummy secrets try: from .secrets import * except ImportError: diff --git a/hknweb/candidate/static/candidate/animal_photo_urls.txt b/hknweb/static/animal_photo_urls.txt similarity index 100% rename from hknweb/candidate/static/candidate/animal_photo_urls.txt rename to hknweb/static/animal_photo_urls.txt diff --git a/hknweb/static/css/base.css b/hknweb/static/css/base.css index 0564c06d..400ad0bb 100644 --- a/hknweb/static/css/base.css +++ b/hknweb/static/css/base.css @@ -22,6 +22,10 @@ body { padding-right: 13px; } +.content { + padding-bottom: 1em; +} + /* * NAVIGATION BAR */ @@ -226,7 +230,8 @@ body { font-weight: 300; text-align: center; margin: 0; - margin-bottom: 2rem; + margin-top: 1rem; + margin-bottom: 1rem; } /* FOOTER */ diff --git a/hknweb/static/css/errors.css b/hknweb/static/css/errors.css new file mode 100644 index 00000000..f19f820a --- /dev/null +++ b/hknweb/static/css/errors.css @@ -0,0 +1,44 @@ +code { + border-radius: 5px; + border: 1px solid black; + background-color: #f0f0f0; + color: red; + width: 10px; + padding: 1px; +} + +.confirm-text { + font-size: 1.3em; + margin-left: 10%; + margin-right: 10%; +} + +.right { + float: right; +} + +.link { + font-family: 'Source Sans Pro', sans-serif; +} + +.links-div { + display: block; + margin-left: auto; + margin-right: auto; + width: 50%; + font-size: 1.3em; +} + +.link-home { + margin-left: 10%; + float: left; + width: 40%; + font-size: 1.3em; +} + +#hiding-cat { + width: 45%; + display: block; /* Center image */ + margin-left: auto; + margin-right: auto; +} diff --git a/hknweb/templates/403.html b/hknweb/templates/403.html new file mode 100644 index 00000000..0152dd59 --- /dev/null +++ b/hknweb/templates/403.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load static %} + +{% block header %} + +{% endblock %} + +{% block title %} 403 Forbidden {% endblock %} + +{% block heading %} +Oops! +{% endblock %} + +{% block content %} + +
    +
    + You do not have access to this page. If this issue persists, contact Compserv or VP. +
    +
    + + hiding cat + +
    + + + +
    + +
    + + + +{% endblock %} diff --git a/hknweb/templates/404.html b/hknweb/templates/404.html new file mode 100644 index 00000000..957faca6 --- /dev/null +++ b/hknweb/templates/404.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% load static %} + +{% block header %} + +{% endblock %} + +{% block title %} 404 Not Found {% endblock %} + +{% block heading %} +Oops! +{% endblock %} + +{% block content %} + + +
    +
    + The page {{ request.path|urlencode }} doesn't exist. If you think this is a mistake, + contact Compserv. +
    +
    + + hiding cat + +
    + + + +
    + +
    + + + +{% endblock %} diff --git a/hknweb/templates/500.html b/hknweb/templates/500.html new file mode 100644 index 00000000..be4c7c50 --- /dev/null +++ b/hknweb/templates/500.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% load static %} + +{% block header %} + +{% endblock %} + +{% block title %} 500 Internal Server Error {% endblock %} + +{% block heading %} +Oops! +{% endblock %} + +{% block content %} + + +
    +
    + We messed up. Someone was stressed about their 189 midterm when writing this code + and let a bug slip though our extensive code reviews. If this issue persists, + contact Compserv. +
    +
    + + hiding cat + +
    + + + +
    + +
    + + + +{% endblock %} diff --git a/hknweb/templates/errors/401.html b/hknweb/templates/errors/401.html deleted file mode 100644 index c12525ff..00000000 --- a/hknweb/templates/errors/401.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% load static %} - -{% block header %} - -{% endblock %} - -{% block title %} 401 Error {% endblock %} - -{% block heading %} -Oops! -{% endblock %} - -{% block content %} - -
    - - You do not have access to this page. If this issue persists, contact CompServ or VP. - -

    - - hiding cat - -
    - - -

    - -
    - - - -{% endblock %} diff --git a/hknweb/urls.py b/hknweb/urls.py index 7328fc06..1f03f22d 100644 --- a/hknweb/urls.py +++ b/hknweb/urls.py @@ -18,9 +18,10 @@ from django.urls import path from django.conf import settings from django.conf.urls.static import static -from . import views + from .views import landing from .views import users + urlpatterns = [ path('admin/', admin.site.urls), path('accounts/', include('django.contrib.auth.urls')), diff --git a/hknweb/utils.py b/hknweb/utils.py new file mode 100644 index 00000000..59946e4d --- /dev/null +++ b/hknweb/utils.py @@ -0,0 +1,38 @@ +from django.contrib.auth.decorators import login_required, permission_required +from django.utils.decorators import method_decorator +from django.contrib.staticfiles.finders import find +from functools import wraps +from random import randint + +# decorators + +def login_and_permission(permission_name): + """ First requires log in, but if you're already logged in but don't have permission, + displays more info. """ + def decorator(func): + return wraps(func)( # preserves function attributes to the decorated function + login_required(login_url='/accounts/login/')( + # raises 403 error which invokes our custom 403.html + permission_required(permission_name, login_url='/accounts/login/', raise_exception=True)( + func # decorates function with both login_required and permission_required + ) + ) + ) + return decorator + +def method_login_and_permission(permission_name): + return method_decorator(login_and_permission(permission_name), name='dispatch') + +# photos + +def get_all_photos(): + """ This function is not used; it can be used to view all photos available. """ + with open(find("animal_photo_urls.txt")) as f: + urls = f.readlines() + return [url.strip() + "?w=400" for url in urls] + +# images from pexels.com +def get_rand_photo(width=400): + with open(find("animal_photo_urls.txt")) as f: + urls = f.readlines() + return urls[randint(0, len(urls) - 1)].strip() + "?w=" + str(width)