from datetime import timedelta
import math
from crispy_forms.helper import FormHelper
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.models import Group
from django.contrib.auth.views import LoginView
from django.core.exceptions import PermissionDenied
from django.db.models import F, Q, Count, Case, When, Sum
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls.base import reverse
from django.utils import timezone
from django.views import generic
from django_saml2_auth.views import signin as saml_login
from data.forms import form_footer
from emails.generators import DefaultLNLEmailGenerator
from events.models import Event2019, OfficeHour, CCReport
from helpers import mixins, challenges
from slack.api import lookup_user, user_profile, check_presence, user_add, user_kick
from . import forms
from .models import Officer, ProfilePhoto, UserPreferences
[docs]class UserAddView(mixins.SetFormMsgMixin, mixins.HasPermMixin, generic.CreateView):
"""Add a new user manually (should rarely be used - LDAP does this for us)"""
form_class = forms.UserAddForm
model = get_user_model()
perms = 'accounts.add_user'
template_name = 'form_crispy.html'
msg = "Add User Manually"
[docs] def get_success_url(self):
return reverse('accounts:detail', args=(self.object.id,))
[docs]class UserUpdateView(mixins.HasPermOrTestMixin, mixins.ConditionalFormMixin, generic.UpdateView):
"""Update user profile"""
slug_field = "username"
slug_url_kwarg = "username"
model = get_user_model()
form_class = forms.UserEditForm
template_name = "form_crispy_cbv.html"
perms = 'accounts.change_user'
[docs] def user_passes_test(self, request, *args, **kwargs):
return request.user and request.user.pk == self.get_object().pk
[docs] def get_context_data(self, **kwargs):
context = super(UserUpdateView, self).get_context_data(**kwargs)
context['msg'] = "Edit user '%s'" % self.object
return context
[docs] def get_success_url(self):
return reverse('accounts:detail', args=(self.object.id,))
[docs]class UserDetailView(mixins.HasPermOrTestMixin, generic.DetailView):
"""View user profile"""
slug_field = "username"
slug_url_kwarg = "username"
model = get_user_model()
template_name = "userdetail.html"
perms = ['accounts.view_user']
[docs] def user_passes_test(self, request, *args, **kwargs):
# members looking at other members is fine, and you should always be able to look at yourself.
object = self.get_object()
if request.user == object:
return True
if object.is_lnl:
return request.user.has_perm('accounts.view_member')
else:
return request.user.has_perm('accounts.view_user', object)
[docs] def get_context_data(self, **kwargs):
context = super(UserDetailView, self).get_context_data(**kwargs)
context['u'] = u = self.object
context['hours'] = u.hours.filter(hours__isnull=False).select_related('event', 'service', 'category')
context['hour_total'] = u.hours.aggregate(hours=Sum('hours'))
context['ccs'] = u.ccinstances.select_related('event').all()
slack_id = lookup_user(u.email)
slack_profile = user_profile(slack_id)
if slack_profile['ok']:
context['slack_id'] = slack_id
context['slack_username'] = slack_profile['user']['name']
context['slack_active'] = check_presence(slack_id)
pending_reports = []
for instance in context['ccs']:
if not CCReport.objects.all().filter(crew_chief=u, event=instance.event).exists():
event = instance.event
if event.datetime_end < timezone.now() and not event.reviewed and not event.closed and \
not event.cancelled:
pending_reports.append(instance)
context['pending_reports'] = pending_reports
context['active'] = self.get_object().is_active
moviesccd = Event2019.objects.filter(ccinstances__crew_chief=self.get_object(),
serviceinstance__service__category__name="Projection").distinct()
context['moviesccd'] = moviesccd.count()
hours = OfficeHour.objects.filter(officer=u)
output = []
for hour in hours:
output.append((hour.get_day, hour.hour_start, hour.hour_end))
context['office_hours'] = output
return context
[docs]class BaseUserList(mixins.HasPermMixin, generic.ListView):
"""Basic structure for user lists"""
model = get_user_model()
context_object_name = 'users'
template_name = 'users.html'
name = "User List"
perms = ['accounts.view_user']
[docs] def get_context_data(self, **kwargs):
context = super(BaseUserList, self).get_context_data(**kwargs)
context['h2'] = self.name
context['accounts_disabled_column'] = self.accounts_disabled_column
context['positions_column'] = self.positions
return context
[docs]class OfficerList(BaseUserList):
"""Lists LNL officers"""
perms = ['accounts.view_member']
queryset = get_user_model().objects.filter(groups__name="Officer")
name = "Officer List"
accounts_disabled_column = False
positions = True
[docs]class ActiveList(BaseUserList):
"""Lists active LNL members"""
perms = ['accounts.view_member']
queryset = get_user_model().objects.filter(groups__name="Active")
name = "Active List"
accounts_disabled_column = False
positions = False
[docs]class AwayList(BaseUserList):
"""Lists LNL members on away status"""
perms = ['accounts.view_member']
queryset = get_user_model().objects.filter(groups__name="Away")
name = "Away List"
accounts_disabled_column = False
positions = False
[docs]class AssociateList(BaseUserList):
"""Lists associate LNL members"""
perms = ['accounts.view_member']
queryset = get_user_model().objects.filter(groups__name="Associate")
name = "Associate List"
accounts_disabled_column = False
positions = False
[docs]class AlumniList(BaseUserList):
"""Lists LNL alumni"""
perms = ['accounts.view_member']
queryset = get_user_model().objects.filter(groups__name="Alumni")
name = "Alumni List"
accounts_disabled_column = False
positions = False
[docs]class InactiveList(BaseUserList):
"""Lists inactive LNL members"""
perms = ['accounts.view_member']
queryset = get_user_model().objects.filter(groups__name="Inactive")
name = "Inactive List"
accounts_disabled_column = True
positions = False
[docs]class AllMembersList(BaseUserList):
"""Lists all LNL members"""
perms = ['accounts.view_member']
queryset = get_user_model().objects.filter(groups__isnull=False).distinct()
name = "All Members List"
accounts_disabled_column = False
positions = False
[docs]class LimboList(BaseUserList):
"""Lists unassociated users"""
queryset = get_user_model().objects.filter(groups__isnull=True)
name = "Users without Association"
accounts_disabled_column = False
positions = False
[docs]class MeDirectView(mixins.LoginRequiredMixin, generic.RedirectView):
"""Redirects to a user's profile page"""
[docs] def get_redirect_url(self, *args, **kwargs):
return super(MeDirectView, self).get_redirect_url(self.request.user.pk, *args, **kwargs)
[docs]def smart_login(request):
"""
Intelligent signin handler. Presents the `Sign in with Microsoft` option if enabled. If already logged in,
redirects to the requested page (can be used to check for an active session). Also checks for the `Prefer SAML`
cookie and automatically attempts to log in via Microsoft SSO if present. Falls back on Django's native login form
otherwise.
"""
pref_saml = request.COOKIES.get('prefer_saml', None)
use_saml = request.GET.get('force_saml', None)
next_url = request.GET.get('next', reverse('home'))
if request.user.is_authenticated:
return HttpResponseRedirect(next_url)
if settings.SAML2_ENABLED and use_saml == "true":
return saml_login(request)
if settings.SAML2_ENABLED and pref_saml == "true":
return saml_login(request)
else:
return LoginView.as_view(template_name='registration/login.html', authentication_form=forms.LoginForm,
extra_context={'background_img': settings.LOGIN_BACKGROUND})(request)
[docs]@login_required
@permission_required('accounts.view_member', raise_exception=True)
def mdc(request):
"""Displays a list of radio MDCs for LNL members"""
context = {}
users_with_mdc = get_user_model().objects.exclude(mdc__isnull=True) \
.exclude(mdc='').order_by('last_name', 'first_name', 'mdc')
members_without_mdc = get_user_model().objects.exclude(mdc__isnull=False) \
.filter(groups__name__in=['Officer', 'Active', 'Away'])
context['users'] = users_with_mdc
context['members_without_mdc'] = members_without_mdc
context['h2'] = "Member MDC List"
return render(request, 'users_mdc.html', context)
[docs]@login_required
@permission_required('accounts.view_member', raise_exception=True)
def mdc_raw(request):
"""Downloads a CSV file containing the radio MDCs of LNL members"""
context = {}
users = get_user_model().objects.exclude(mdc__isnull=True) \
.exclude(mdc='').order_by('last_name')
context['users'] = users
response = render(request, 'users_mdc_raw.csv', context)
response['Content-Disposition'] = 'attachment; filename="lnl_mdc.csv"'
response['Content-Type'] = 'text/csv'
return response
[docs]@login_required
@permission_required('accounts.change_membership', raise_exception=True)
def secretary_dashboard(request):
"""
Dashboard for the secretary. Lists important member counts used in voting and suggests users to activate,
deactivate, associate, or take off away status.
"""
semester_ago = timezone.now() - timedelta(weeks=17)
term_ago = timezone.now() - timedelta(weeks=8)
context = {}
num_active = get_user_model().objects.filter(groups__name='Active').count()
simple_majority = int(math.ceil(num_active / 2.0)) + 1
two_thirds_majority = int(math.ceil(num_active * 2 / 3.0))
members_to_activate = get_user_model().objects.filter(groups__name='Associate') \
.annotate(hours_count=Count(Case(When(hours__event__datetime_start__gte=semester_ago, then=F('hours'))),
distinct=True)).filter(hours_count__gte=5) \
.annotate(
meeting_count=Count(Case(When(meeting__datetime__gte=semester_ago, then=F('meeting'))), distinct=True)).filter(
meeting_count__gte=3)
members_to_deactivate = get_user_model().objects.filter(groups__name='Active').exclude(groups__name='Away') \
.annotate(hours_count=Count(Case(When(hours__event__datetime_start__gte=term_ago, then=F('hours'))),
distinct=True)).filter(hours_count__lt=4) \
.annotate(meeting_count=Count(
Case(When(meeting__datetime__gte=term_ago, meeting__meeting_type__name='General', then=F('meeting'))),
distinct=True)).filter(meeting_count__lt=4)
members_to_associate = get_user_model().objects.filter(groups=None).filter(
Q(meeting__datetime__gte=term_ago) | Q(hours__event__datetime_start__gte=term_ago)).distinct()
members_on_away = get_user_model().objects.filter(groups__name='Away', away_exp__isnull=False)
context['num_active'] = num_active
context['simple_majority'] = simple_majority
context['two_thirds_majority'] = two_thirds_majority
context['members_to_activate'] = members_to_activate
context['members_to_deactivate'] = members_to_deactivate
context['members_to_associate'] = members_to_associate
context['members_on_away'] = members_on_away
return render(request, 'users_secretary_dashboard.html', context)
[docs]@login_required
@permission_required('accounts.view_member', raise_exception=True)
def shame(request):
"""
The LNL Crew Chief Report Hall of Shame. Tracks members who fail to complete event reports and lists the top 10
on a leaderboard.
"""
context = {}
worst_cc_report_forgetters = get_user_model().objects.annotate(Count('ccinstances', distinct=True)).annotate(
did_ccreport_count=Count(Case(When(ccinstances__event__ccreport__crew_chief=F('pk'), then=F('ccinstances'))),
distinct=True)).annotate(
failed_to_do_ccreport_count=(F('ccinstances__count') - F('did_ccreport_count'))).annotate(
failed_to_do_ccreport_percent=(F('failed_to_do_ccreport_count') * 100 / F('ccinstances__count'))).order_by(
'-failed_to_do_ccreport_count', '-failed_to_do_ccreport_percent')[:10]
context['worst_cc_report_forgetters'] = worst_cc_report_forgetters
return render(request, 'users_shame.html', context)
[docs]class PasswordSetView(mixins.SetFormMsgMixin, generic.FormView):
"""Set a non-SSO login password"""
model = get_user_model()
user = None
template_name = 'form_crispy.html'
msg = "Set Non-SSO Login Password"
[docs] def get_success_url(self):
return reverse('accounts:detail', args=[self.user.pk])
[docs] def get_context_data(self, **kwargs):
context = super(PasswordSetView, self).get_context_data(**kwargs)
form = self.get_form()
context['form'] = form
form.helper = FormHelper(form)
form.helper.layout.fields.append(form_footer("Set Password"))
return context
[docs] def dispatch(self, request, pk, *args, **kwargs):
self.user = get_object_or_404(get_user_model(), pk=int(pk))
return super(PasswordSetView, self).dispatch(request, pk, *args, **kwargs)
[docs]@login_required
def user_preferences(request):
"""Update a user's account preferences (only visible to the user)"""
context = {'title': 'My Preferences', 'submit_btn': {'text': 'Save'}}
user = request.user
context['user'] = user
prefs, created = UserPreferences.objects.get_or_create(user=request.user)
form = forms.UserPreferencesForm(instance=prefs)
if request.POST:
form = forms.UserPreferencesForm(request.POST, instance=prefs)
if form.is_valid():
obj = form.save(commit=False)
obj.event_edited_field_subscriptions += ['location', 'datetime_setup_complete', 'datetime_start', 'datetime_end']
if request.POST.get('submit', None) == 'rt-delete':
obj.rt_token = None
obj.save()
form.save_m2m()
return HttpResponseRedirect(reverse("accounts:preferences"))
obj.save()
form.save_m2m()
messages.success(request, "Your preferences have been updated successfully!")
return HttpResponseRedirect(reverse("accounts:detail", args=[user.pk]))
else:
form_data = form.cleaned_data
form_data['srv'] = ['email', 'slack', 'sms']
form.data = form_data
context['form'] = form
return render(request, 'form_semanticui.html', context)
[docs]@login_required
@permission_required('accounts.change_membership', raise_exception=True)
def officer_photos(request, pk=None):
"""Update officer headshot (displayed on the main LNL website about page)"""
context = {}
context['msg'] = "Update Officer Photo"
if pk is None:
pk = request.user.pk
officer = get_object_or_404(get_user_model(), pk=pk)
if not challenges.is_officer(officer):
messages.add_message(request, messages.ERROR, 'This feature is not available for this user.')
return HttpResponseRedirect(reverse("home"))
context['officer'] = officer
img = ProfilePhoto.objects.filter(officer=officer).first()
form = forms.OfficerPhotoForm(instance=img)
if request.method == "POST":
form = forms.OfficerPhotoForm(request.POST, request.FILES, instance=img)
if request.POST['save'] == "Remove":
if img is not None:
img.delete()
messages.success(request, "Your profile photo was removed successfully!", extra_tags='success')
return HttpResponseRedirect(reverse("accounts:detail", args=[officer.pk]))
if form.is_valid():
form.instance.officer = officer
photo = form.save()
officer_info = Officer.objects.filter(user=officer).first()
if officer_info:
officer_info.img = photo
officer_info.save()
messages.success(request, "Your profile photo was updated successfully!", extra_tags='success')
return HttpResponseRedirect(reverse("accounts:detail", args=[officer.pk]))
else:
context['form'] = form
else:
context['form'] = form
return render(request, 'form_crispy.html', context)
[docs]@login_required
def application_scope_request(request):
"""
Prompt the user to allow applications connected to the LNLDB to interact with one another. Redirects to the
application's callback uri.
"""
context = {}
app_parameters = request.session
try:
context['app'] = app_parameters['app']
context['resource'] = app_parameters['resource']
context['icon'] = app_parameters['icon']
context['scopes'] = app_parameters['scopes']
context['callback_uri'] = app_parameters['callback_uri']
inverted = app_parameters['inverted']
except KeyError:
return HttpResponseRedirect(reverse("home"))
del request.session['app']
del request.session['resource']
del request.session['icon']
del request.session['scopes']
del request.session['callback_uri']
del request.session['inverted']
invert = False
prefs, created = UserPreferences.objects.get_or_create(user=request.user)
if inverted:
invert = True
elif inverted is None:
invert = prefs.theme == "dark"
context['inverted'] = invert
return render(request, 'scope_request.html', context)