Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial Communicator Dashboard Work #388

Merged
merged 13 commits into from
Nov 16, 2023
16 changes: 16 additions & 0 deletions core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,21 @@


from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User
from core.models import ExtendedUser
from programs.models import College, Department

# Register your models here.
class ExtendedUserInline(admin.StackedInline):
model = ExtendedUser
can_delete = False
verbose_name_plural = 'Program Permissions'

class UserAdmin(BaseUserAdmin):
inlines = [
ExtendedUserInline
]

admin.site.unregister(User)
admin.site.register(User, UserAdmin)
4 changes: 4 additions & 0 deletions core/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@

class CoreConfig(AppConfig):
name = 'core'

def ready(self):
import core.signals
return super().ready()
19 changes: 19 additions & 0 deletions core/filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import django_filters

class ProgramListFilterSet(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='icontains')
order = django_filters.OrderingFilter(
fields = (
('name', 'name'),
('modified', 'modified')
),
field_labels = {
'name': 'Name',
'modified': 'Last Modified'
}
)


def __init__(self, data, *args, **kwargs):
data = data.copy()
super().__init__(data, *args, **kwargs)
38 changes: 38 additions & 0 deletions core/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 3.2.20 on 2023-11-08 13:46

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

initial = True

def create_extendeduser_records(apps, schema_editor):
ExtendedUser = apps.get_model('core', 'ExtendedUser')
User = apps.get_model('auth', 'User')

users = User.objects.all()

for user in users:
extended_user = ExtendedUser.objects.create(user=user)
extended_user.save()


dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='ExtendedUser',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('colleges_can_edit', models.ManyToManyField(related_name='editors', to='programs.College')),
('departments_can_edit', models.ManyToManyField(related_name='editors', to='programs.Department')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.RunPython(create_extendeduser_records)
]
33 changes: 33 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@


from django.db import models
from django.contrib.auth.models import User
from django.conf import settings

from programs.models import College, Department, Program

# Create your models here.

Expand All @@ -15,3 +19,32 @@ def get_prep_value(self, value):
if value is not None:
return value.lower()
return ''

class ExtendedUser(models.Model):
user = models.OneToOneField(User, related_name='meta', on_delete=models.CASCADE)
colleges_can_edit = models.ManyToManyField(College, blank=True, related_name='editors')
departments_can_edit = models.ManyToManyField(Department, blank=True, related_name='editors')

@property
def editable_programs(self):
"""
Returns all the programs the user
has access to edit.
"""
if self.user.is_superuser:
return Program.objects.all()
elif self.colleges_can_edit.count() == 0 or self.departments_can_edit.count() == 0:
return Program.objects.none()
else:
return Program.objects.filter(
models.Q(colleges__in=self.colleges_can_edit) |
models.Q(departments__in=self.departments_can_edit)
)

@property
def programs_missing_descriptions_count(self) -> int:
"""
Returns the number of programs missing
custom descriptions
"""
return self.editable_programs.count() - self.editable_programs.filter(descriptions__description_type=settings.CUSTOM_DESCRIPTION_TYPE_ID).count()
12 changes: 12 additions & 0 deletions core/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.contrib.auth.models import User
from core.models import ExtendedUser

from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender=User)
def on_user_post_save(sender, **kwargs):
instance = kwargs.get('instance')
created = kwargs.get('created', False)
if created:
ExtendedUser.objects.create(user=instance).save()
Empty file added core/templatetags/__init__.py
Empty file.
32 changes: 32 additions & 0 deletions core/templatetags/core_extras.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from django import template

register = template.Library()

@register.simple_tag(takes_context=True)
def param_replace(context, **kwargs):
ctx = context['request'].GET.copy()
for key, val in kwargs.items():
ctx[key] = val
for key in [key for key, val in ctx.items() if not val]:
del ctx[key]
return ctx.urlencode()

@register.inclusion_tag('templatetags/sortable-field-heading.html', takes_context=True)
def sortable_field_header(context, **kwargs):
ctx = context['request'].GET.copy()
current_order = ctx.get('order', None)
field = kwargs.get('field')
fieldname = kwargs.get('fieldname')

retval = {
'fieldname': fieldname,
'field': field,
'show_caret': False
}

if current_order in [field, f"-{field}"]:
retval['field'] = field if current_order.startswith('-') else f"-{field}"
retval['order'] = 'up' if current_order.startswith('-') else 'down'
retval['show_caret'] = True

return {**context.flatten(), **retval}
15 changes: 15 additions & 0 deletions core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@
HomeView.as_view(),
name='home'
),
url(
r'manager/dashboard/$',
CommunicatorDashboard.as_view(),
name='dashboard'
),
url(
r'manager/dashboard/programs/$',
ProgramListing.as_view(),
name='dashboard.programs.list'
),
url(
r'manager/dashboard/programs/(?P<pk>\d+)/$',
ProgramEditView.as_view(),
name='dashboard.programs.edit'
),
url(
r'^manager/search/$',
SearchView.as_view(template_name='search.html'),
Expand Down
85 changes: 85 additions & 0 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@

from django.shortcuts import render
from django.views.generic.base import TemplateView
from django.views.generic import ListView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin
from rest_framework.views import APIView
from rest_framework.response import Response

from programs.models import Program

from core.filters import ProgramListFilterSet

import settings

class TitleContextMixin(object):
Expand All @@ -23,6 +28,20 @@ def get_context_data(self, **kwargs):

return context

class FilteredListView(ListView):
filterset = None

def get_queryset(self, queryset=None):
if not queryset:
queryset = super().get_queryset()
self.filterset = self.filterset_class(self.request.GET, queryset=queryset)
return self.filterset.qs.distinct()

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['filterset'] = self.filterset
return context

# Create your views here.
class HomeView(TitleContextMixin, TemplateView):
template_name = 'home.html'
Expand All @@ -44,3 +63,69 @@ def get(request, format=None, **kwargs):
'ucf_events_api': settings.UCF_EVENTS_API,
'ucf_search_service_api': settings.UCF_SEARCH_SERVICE_API
})

# Communicator Dashboard Views
class CommunicatorDashboard(LoginRequiredMixin, TitleContextMixin, TemplateView):
template_name = 'dashboard/home.html'
title = ''
heading = 'Communicator Dashboard'
local = settings.LOCAL

def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
user = self.request.user
ctx['most_recent_import'] = user.meta.editable_programs.latest('modified').modified
ctx['recently_added'] = user.meta.editable_programs.order_by('-created')[:10]
ctx['meta'] = {
'program_count': user.meta.editable_programs.count(),
'missing_desc_count': user.meta.programs_missing_descriptions_count
}
return ctx


class ProgramListing(LoginRequiredMixin, TitleContextMixin, FilteredListView):
template_name = 'dashboard/program-list.html'
title = 'Programs'
heading = 'Programs'
local = settings.LOCAL
filterset_class = ProgramListFilterSet
paginate_by = 20

def get_queryset(self):
return super().get_queryset(self.request.user.meta.editable_programs)

class ProgramEditView(LoginRequiredMixin, TitleContextMixin, UpdateView):
template_name = 'dashboard/program-edit.html'
title = 'Edit Program'
heading = 'Edit Program'
local = settings.LOCAL
fields = ('name',)

def get_queryset(self):
return self.request.user.meta.editable_programs

def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
obj: Program = ctx['object']
ctx['read_only_fields'] = {
'Name': obj.name,
'Credit Hours': obj.credit_hours,
'Plan Code': obj.plan_code,
'Subplan Code': obj.subplan_code,
'CIP': obj.current_cip,
'Catalog URL': obj.catalog_url,
'Colleges': obj.colleges,
'Departments': obj.departments,
'Level': obj.level,
'Career': obj.career,
'Degree': obj.degree,
'Online': obj.online,
'Created': obj.created,
'Last Modified': obj.modified,
'Resident Tuition': obj.resident_tuition,
'Non-Resident Tuition': obj.nonresident_tuition,
'Active': obj.active,
'Graduate Slate ID': obj.graduate_slate_id,
'Valid': obj.valid
}
return ctx
6 changes: 4 additions & 2 deletions programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ def area_code_str(self) -> str:
string if the code is not set.
"""
return str(self.area).zfill(2) if self.area is not None else ""

@property
def subarea_code_str(self) -> str:
"""
Expand All @@ -160,7 +160,7 @@ def subarea_code_str(self) -> str:
string if the code is not set.
"""
return str(self.subarea).zfill(2) if self.subarea is not None else ""

@property
def precise_code_str(self) -> str:
"""
Expand Down Expand Up @@ -662,6 +662,8 @@ class ProgramProfile(models.Model):
related_name='profiles',
on_delete=models.CASCADE
)
created = models.DateTimeField(auto_now_add=True, null=False)
modified = models.DateTimeField(auto_now=True, null=False)

class Meta:
unique_together = ('profile_type', 'program')
Expand Down
2 changes: 1 addition & 1 deletion programs/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ def get_subarea_of_interest(self, obj: Program):
return cip_name
except CIP.DoesNotExist:
return None

class Meta:
fields = (
'id',
Expand Down
3 changes: 3 additions & 0 deletions settings_local.tmpl.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@
# Set the current employment projection report year range
PROJ_CURRENT_REPORT = '1828'

# The ID of the "Custom" description type for programs
CUSTOM_DESCRIPTION_TYPE_ID = 5

# The default year + month + day limit of how old imported images can be.
# NOTE: increasing this value could cause older images to be removed
# during a future image import!
Expand Down
1 change: 1 addition & 0 deletions templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<!-- Athena CSS -->
<link rel="stylesheet" href="https://cdn.ucf.edu/athena-framework/latest/css/framework.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
</head>
<body data-spy="scroll" data-target="#sidebar">
<header>
Expand Down
Loading