# # steering, Programme/schedule management for AURA # # Copyright (C) 2011-2017, 2020, Ernesto Rico Schmidt # Copyright (C) 2017-2019, Ingo Leindecker # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) any # later version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # from datetime import date, datetime, timedelta from django.conf import settings from django.contrib import admin from django.core.exceptions import ObjectDoesNotExist from django.shortcuts import render from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from .forms import MusicFocusForm from .models import Language, Type, MusicFocus, Category, Topic, FundingCategory, Host, Note, RRule, Schedule, Show, TimeSlot class ActivityFilter(admin.SimpleListFilter): title = _("Activity") def lookups(self, request, model_admin): return ( ('yes', _("active")), ('no', _("inactive")) ) def queryset(self, request, queryset): if self.parameter_name == 'has_timeslots': # active/inactive Schedules if self.value() == 'yes': return queryset.filter(until__gt=timezone.now()).distinct() if self.value() == 'no': return queryset.filter(until__lt=timezone.now()).distinct() if self.parameter_name == 'has_schedules_timeslots': # active/inactive Shows if self.value() == 'yes': return queryset.filter(schedules__until__gt=timezone.now()).distinct() if self.value() == 'no': return queryset.filter(schedules__until__lt=timezone.now()).distinct() if self.parameter_name == 'has_shows_schedules_timeslots': # active/inactive Hosts if self.value() == 'yes': return queryset.filter(shows__schedules__until__gt=timezone.now()).distinct() if self.value() == 'no': return queryset.filter(shows__schedules__until__lt=timezone.now()).distinct() class ActiveSchedulesFilter(ActivityFilter): parameter_name = 'has_timeslots' class ActiveShowsFilter(ActivityFilter): parameter_name = 'has_schedules_timeslots' class ActiveHostsFilter(ActivityFilter): parameter_name = 'has_shows_schedules_timeslots' class TypeAdmin(admin.ModelAdmin): list_display = ('type', 'admin_color', 'is_active') list_filter = ('is_active',) prepopulated_fields = {'slug': ('type',)} class MusicFocusAdmin(admin.ModelAdmin): form = MusicFocusForm list_display = ('focus', 'abbrev', 'admin_buttons', 'is_active') list_filter = ('is_active',) prepopulated_fields = {'slug': ('focus',)} class CategoryAdmin(admin.ModelAdmin): list_display = ('category', 'abbrev', 'admin_buttons', 'is_active') list_filter = ('is_active',) prepopulated_fields = {'slug': ('category',)} class LanguageAdmin(admin.ModelAdmin): list_display = ('name', 'is_active') list_filter = ('is_active',) class TopicAdmin(admin.ModelAdmin): list_display = ('topic', 'abbrev', 'admin_buttons', 'is_active') list_filter = ('is_active',) prepopulated_fields = {'slug': ('topic',)} class FundingCategoryAdmin(admin.ModelAdmin): list_display = ('fundingcategory', 'abbrev', 'is_active') list_filter = ('is_active',) prepopulated_fields = {'slug': ('fundingcategory',)} class HostAdmin(admin.ModelAdmin): list_display = ('name', 'email', 'is_active') list_filter = (ActiveHostsFilter, 'is_active',) def get_queryset(self, request): if request.user.is_superuser: return Host.objects.all() # Common users only see hosts of shows they own return Host.objects.filter(shows__in=request.user.shows.all()).distinct() class NoteAdmin(admin.ModelAdmin): date_hierarchy = 'start' list_display = ('title', 'show', 'start', 'status', 'user') fields = (('show', 'timeslot'), 'title', 'slug', 'summary', 'content', 'image', 'host', 'status', 'cba_id') prepopulated_fields = {'slug': ('title',)} list_filter = ('status',) ordering = ('timeslot',) save_as = True class Media: js = [settings.MEDIA_URL + 'js/calendar/lib/moment.min.js', settings.MEDIA_URL + 'js/note_change.js', ] def get_queryset(self, request): if request.user.is_superuser: shows = Show.objects.all() else: # Commons users only see notes of shows they own shows = request.user.shows.all() return super(NoteAdmin, self).get_queryset(request).filter(show__in=shows) def formfield_for_foreignkey(self, db_field, request=None, **kwargs): four_weeks_ago = timezone.now() - timedelta(weeks=4) in_twelve_weeks = timezone.now() + timedelta(weeks=12) if db_field.name == 'timeslot': # Adding/Editing a note: load timeslots of the user's shows into the dropdown # TODO: Don't show any timeslot in the select by default. # User should first choose show, then timeslots are loaded into the select via ajax. # # How to do this while not constraining the queryset? # Saving won't be possible otherwise, if queryset doesn't contain the selectable elements beforehand # kwargs['queryset'] = TimeSlot.objects.filter(show=-1) # Superusers see every timeslot for every show if request.user.is_superuser: kwargs['queryset'] = TimeSlot.objects.filter(start__gt=four_weeks_ago, start__lt=in_twelve_weeks) # note__isnull=True # Users see timeslots of shows they own else: kwargs['queryset'] = TimeSlot.objects.filter(show__in=request.user.shows.all(), start__gt=four_weeks_ago, start__lt=in_twelve_weeks) # note__isnull=True if db_field.name == 'show': # Adding/Editing a note: load user's shows into the dropdown # Common users only see shows they own if not request.user.is_superuser: kwargs['queryset'] = Show.objects.filter(pk__in=request.user.shows.all()) if db_field.name == 'host': # Common users only see hosts of shows they own if not request.user.is_superuser: kwargs['queryset'] = Host.objects.filter(shows__in=request.user.shows.all(), is_active=True).distinct() return super(NoteAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) def save_model(self, request, obj, form, change): # Save the creator when adding a note if not change: obj.user = request.user # Try to get direct audio URL from CBA obj.audio_url = Note.get_audio_url(obj.cba_id) obj.save() # Save the note id to table timeslot as well timeslot = TimeSlot.objects.get(pk=obj.timeslot_id) timeslot.note_id = obj.id timeslot.save() class TimeSlotInline(admin.TabularInline): model = TimeSlot ordering = ('-end',) class TimeSlotAdmin(admin.ModelAdmin): model = TimeSlot class ScheduleAdmin(admin.ModelAdmin): actions = ('renew',) inlines = (TimeSlotInline,) fields = ( ('rrule', 'byweekday'), ('tstart', 'tend'), 'dstart', 'until', 'is_repetition', ('add_days_no', 'add_business_days_only'), 'default_id', 'automation_id',) list_display = ('get_show_name', 'byweekday', 'rrule', 'tstart', 'tend', 'until') list_filter = (ActiveSchedulesFilter, 'byweekday', 'rrule', 'is_repetition') ordering = ('byweekday', 'dstart') save_on_top = True search_fields = ('show__name',) def renew(self, request, queryset): next_year = date.today().year + 1 until = date(next_year, 12, 31) renewed = queryset.update(until=until) if renewed == 1: message = _("1 schedule was renewed until %s") % until else: message = _("%s schedule were renewed until %s") % (renewed, until) self.message_user(request, message) renew.short_description = _("Renew selected schedules") def get_show_name(self, obj): return obj.show.name get_show_name.admin_order_field = 'show' get_show_name.short_description = "Show" class ScheduleInline(admin.TabularInline): model = Schedule ordering = ('pk', '-until', 'byweekday') class ShowAdmin(admin.ModelAdmin): filter_horizontal = ('hosts', 'owners', 'musicfocus', 'category', 'topic', 'language') inlines = (ScheduleInline,) list_display = ('name', 'short_description') list_filter = (ActiveShowsFilter, 'type', 'category', 'topic', 'musicfocus', 'language', 'fundingcategory', 'is_public') ordering = ('slug',) prepopulated_fields = {'slug': ('name',)} search_fields = ('name', 'short_description', 'description') fields = ( 'predecessor', 'type', 'name', 'slug', 'image', 'logo', 'short_description', 'description', 'email', 'website', 'hosts', 'owners', 'language', 'category', 'fundingcategory', 'topic', 'musicfocus', 'default_id', 'cba_series_id', 'is_active', 'is_public' ) class Media: js = [settings.MEDIA_URL + 'js/calendar/lib/moment.min.js', settings.MEDIA_URL + 'js/show_change.js', ] css = {'all': ('/program/styles.css',)} def get_queryset(self, request): if request.user.is_superuser: # Superusers see all shows shows = Show.objects.all() else: # Users only see shows they own shows = request.user.shows.all() return super(ShowAdmin, self).get_queryset(request).filter(pk__in=shows) def get_readonly_fields(self, request, obj=None): """Limit field access for common users""" if not request.user.is_superuser: # TODO: how to set field 'name' readonly although it's required? return 'predecessor', 'type', 'hosts', 'owners', 'language', 'category', 'topic', 'musicfocus', 'fundingcategory' return list() def formfield_for_foreignkey(self, db_field, request=None, **kwargs): try: show_id = int(request.get_full_path().split('/')[-2]) except ValueError: show_id = None if db_field.name == 'predecessor' and show_id: kwargs['queryset'] = Show.objects.exclude(pk=show_id) if db_field.name == 'type': kwargs['queryset'] = Type.objects.filter(is_active=True) if db_field.name == 'fundingcategory': kwargs['queryset'] = FundingCategory.objects.filter(is_active=True) return super(ShowAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) def formfield_for_manytomany(self, db_field, request, **kwargs): if db_field.name == 'hosts': kwargs["queryset"] = Host.objects.filter(is_active=True) if db_field.name == 'language': kwargs["queryset"] = Language.objects.filter(is_active=True) if db_field.name == 'category': kwargs["queryset"] = Category.objects.filter(is_active=True) if db_field.name == 'topic': kwargs["queryset"] = Topic.objects.filter(is_active=True) if db_field.name == 'musicfocus': kwargs["queryset"] = MusicFocus.objects.filter(is_active=True) return super(ShowAdmin, self).formfield_for_manytomany(db_field, request, **kwargs) def save_formset(self, request, form, formset, change): """ Is called after the "save show"-form or collision-form were submitted Saves the show after first submit If any changes in schedules happened * added/changed schedules are used to generate new timeslots and matched against existing ones, which will be displayed in the collision form If a collision form was submitted * save the current schedule * delete/create timeslots and relink notes after confirmation Each step passes on to response_add or response_change which will * either display the collision form for the next step * or redirect to the original show-form if the resolving process has been finished (= if either max_steps was surpassed or end_reached was True) """ self.end_reached = False schedule_instances = formset.save(commit=False) # If there are no schedules to save, do nothing if schedule_instances: show_id = schedule_instances[0].show.id else: self.end_reached = True schedule = [] timeslots = [] max_steps = int(len(schedule_instances)) if len(schedule_instances) > 0 else 1 step = 1 if request.POST.get('step') is None: # First save-show submit # Generate thumbnails if form.instance.image.name and settings.THUMBNAIL_SIZES: for size in settings.THUMBNAIL_SIZES: thumbnail = form.instance.image.crop[size].name # Save show data only form.save() # Delete schedules (as well as related timeslots and notes) if flagged as such for obj in formset.deleted_objects: obj.delete() # If nothing else changed, do nothing and redirect to show-form if not formset.changed_objects and not formset.new_objects: self.end_reached = True else: # If a collision form was submitted step = int(request.POST.get('step')) if request.POST.get('num_inputs') is not None and int(request.POST.get('num_inputs')) > 0: print("Resolving conflicts...") # Declare and retrieve variables # Either datetimes as string (e.g. '2017-01-01 00:00:00 - 2017-01-01 01:00:00') to create # or ints of colliding timeslots to keep otherwise resolved_timeslots = [] # IDs of colliding timeslots found in the db. If there's no corresponding collision to the # same index in create_timeslot, value will be None collisions = [] # Datetimes as string (e.g. '2017-01-01 00:00:00 - 2017-01-01 01:00:00') for timeslots to create create_timeslots = [] # IDs of timeslots to delete delete_timeslots = set() # Number of timeslots to be generated num_inputs = int(request.POST.get('num_inputs')) # Numbers of notes to relink for existing timeslots and newly created ones # each of them relating to one of these POST vars: # POST.ntids[idx][id] and POST.ntids[idx][note_id] contain ids of existing timeslots and note_ids to link, while # POST.ntind[idx][id] and POST.ntind[idx][note_id] contain indices of corresponding elements in create_timeslots # and note_ids which will be linked after they're created and thus split into two lists beforehand num_ntids = int(request.POST.get('num_ntids')) num_ntind = int(request.POST.get('num_ntind')) # Retrieve POST vars of current schedule schedule_id = int(request.POST.get('ps_save_id')) if request.POST.get('ps_save_id') != 'None' else None rrule = RRule.objects.get(pk=int(request.POST.get('ps_save_rrule_id'))) show = Show.objects.get(pk=show_id) byweekday = int(request.POST.get('ps_save_byweekday')) tstart = datetime.strptime(request.POST.get('ps_save_tstart'), '%H:%M').time() tend = datetime.strptime(request.POST.get('ps_save_tend'), '%H:%M').time() dstart = datetime.strptime(request.POST.get('ps_save_dstart'), '%Y-%m-%d').date() if dstart < timezone.now().date(): # Create or delete upcoming timeslots only dstart = timezone.now().date() until = datetime.strptime(request.POST.get('ps_save_until'), '%Y-%m-%d').date() is_repetition = request.POST.get('ps_save_is_repetition') automation_id = int(request.POST.get('ps_save_automation_id')) if request.POST.get( 'ps_save_automation_id') != 'None' else 0 default_id = int(request.POST.get('ps_save_default_id')) if request.POST.get( 'ps_save_default_id') != 'None' else 0 add_days_no = int(request.POST.get('ps_save_add_days_no')) if request.POST.get( 'ps_save_add_days_no') != 'None' and int(request.POST.get('ps_save_add_days_no')) > 0 else None add_business_days_only = request.POST.get('ps_save_add_business_days_only') # Put timeslot POST vars into lists with same indices for i in range(num_inputs): resolved_ts = request.POST.get('resolved_timeslots[' + str(i) + ']') if resolved_ts: resolved_timeslots.append(resolved_ts) create_timeslots.append(request.POST.get('create_timeslots[' + str(i) + ']')) # May contain None collisions.append(request.POST.get('collisions[' + str(i) + ']')) # May contain None else: num_inputs -= 1 # Prepare resolved timeslots # Separate timeslots to delete from those to create keep_collisions = [] for x in range(num_inputs): if resolved_timeslots[x] is None or resolved_timeslots[x].isdigit(): # If it's a digit, keep the existing timeslot by preventing the new one from being created create_timeslots[x] = None keep_collisions.append(int(collisions[x])) else: # Otherwise collect the timeslot ids to be deleted later if len(collisions[x]) > 0: delete_timeslots.add(int(collisions[x])) # Collect IDs of upcoming timeslots of the same schedule to delete except those in keep_collision if schedule_id: for ts in TimeSlot.objects.filter(start__gte=dstart, end__lte=until, schedule_id=schedule_id).exclude( pk__in=keep_collisions).values_list('id', flat=True): delete_timeslots.add(ts) # Save schedule new_schedule = Schedule(pk=schedule_id, rrule=rrule, byweekday=byweekday, show=show, dstart=dstart, tstart=tstart, tend=tend, until=until, is_repetition=is_repetition, automation_id=automation_id, default_id=default_id, add_days_no=add_days_no, add_business_days_only=add_business_days_only) # Only save schedule if any timeslots changed if len(resolved_timeslots) > 0: new_schedule.save() # Relink notes to existing timeslots and prepare those to be linked # Relink notes with existing timeslot ids for i in range(num_ntids): try: note = Note.objects.get(pk=int(request.POST.get('ntids[' + str(i) + '][note_id]'))) note.timeslot_id = int(request.POST.get('ntids[' + str(i) + '][id]')) note.save(update_fields=["timeslot_id"]) print("Rewrote note " + str(note.id) + "...to timeslot_id " + str(note.timeslot_id)) except ObjectDoesNotExist: pass # Put list indices of yet to be created timeslots and note_ids in corresponding lists to relink them during creation note_indices = [] note_ids = [] for i in range(num_ntind): note_indices.append(int(request.POST.get('ntind[' + str(i) + '][id]'))) note_ids.append(int(request.POST.get('ntind[' + str(i) + '][note_id]'))) # Database changes for resolved timeslots and relinked notes for newly created for idx, ts in enumerate(create_timeslots): if ts: start_end = ts.split(' - ') # Only create upcoming timeslots if datetime.strptime(start_end[0], "%Y-%m-%d %H:%M:%S") > timezone.now(): timeslot_created = TimeSlot.objects.create(schedule=new_schedule, is_repetition=new_schedule.is_repetition, start=start_end[0], end=start_end[1]) # Link a note to the new timeslot if idx in note_indices: note_idx = note_indices.index(idx) # Get the note_id's index... note_id = note_ids[note_idx] # ...which contains the note_id to relate to try: note = Note.objects.get(pk=note_id) note.timeslot_id = timeslot_created.id note.save(update_fields=["timeslot_id"]) print("Timeslot " + str(timeslot_created.id) + " linked to note " + str(note_id)) except ObjectDoesNotExist: pass # Finally delete discarded timeslots for timeslot_id in delete_timeslots: TimeSlot.objects.filter(pk=timeslot_id).delete() if step > max_steps: self.end_reached = True # Everything below here is called when a new collision is loaded before being handed over to the client # Generate timeslots from current schedule k = 1 for instance in schedule_instances: if isinstance(instance, Schedule): if k == step: timeslots = Schedule.generate_timeslots(instance) schedule = instance break k += 1 # Get collisions for timeslots collisions = Schedule.get_collisions(timeslots) # Get notes of colliding timeslots notes = [] for id in collisions: try: notes.append(Note.objects.get(timeslot_id=id)) except ObjectDoesNotExist: pass self.schedule = schedule self.timeslots = timeslots self.collisions = collisions self.num_collisions = len( [s for s in self.collisions if s != 'None']) # Number of real collisions displayed to the user self.notes = notes self.showform = form self.schedulesform = formset self.step = step + 1 # Becomes upcoming step self.max_steps = max_steps # Pass it on to response_add() or response_change() return self def response_add(self, request, obj, post_url_continue=None): return ShowAdmin.respond(self, request, obj) def response_change(self, request, obj): return ShowAdmin.respond(self, request, obj) def respond(self, request, obj): """ Redirects to the show-change-form if no schedules changed or resolving has been finished (or any other form validation error occured) Displays the collision form for the current schedule otherwise """ # Never check for collisions if not superuser # Common users can't edit the formset, so save_formset() will never be called thus end_reached wasn't set yet if not request.user.is_superuser: self.end_reached = True if self.end_reached: return super(ShowAdmin, self).response_change(request, obj) timeslots_to_collisions = list(zip(self.timeslots, self.collisions)) return render(request, 'collisions.html', {'self': self, 'obj': obj, 'request': request, 'timeslots': self.timeslots, 'collisions': self.collisions, 'schedule': self.schedule, 'timeslots_to_collisions': timeslots_to_collisions, 'schedulesform': self.schedulesform, 'showform': self.showform, 'num_inputs': len(self.timeslots), 'step': self.step, 'max_steps': self.max_steps, 'now': timezone.now(), 'num_collisions': self.num_collisions}) admin.site.register(Language, LanguageAdmin) admin.site.register(Type, TypeAdmin) admin.site.register(MusicFocus, MusicFocusAdmin) admin.site.register(Category, CategoryAdmin) admin.site.register(Topic, TopicAdmin) admin.site.register(FundingCategory, FundingCategoryAdmin) admin.site.register(Host, HostAdmin) admin.site.register(Note, NoteAdmin) admin.site.register(Schedule, ScheduleAdmin) admin.site.register(TimeSlot, TimeSlotAdmin) admin.site.register(Show, ShowAdmin)