from django.contrib import admin from django.db import models from django.db.models import Q from django.forms import ModelForm, BaseInlineFormSet from django.utils import timezone from django.core.urlresolvers import reverse from django.utils.html import format_html from django.core.mail import mail_managers from django.conf.urls import url from django.template.response import TemplateResponse from django.core.serializers import serialize from django.http import HttpResponse from django.db.models.functions import Cast from django.contrib.postgres.aggregates import StringAgg from django.db import connection from django.core.cache import cache from django.contrib.humanize.templatetags.humanize import naturaltime from djgeojson.views import GeoJSONLayerView from urllib.parse import urlencode from functools import partial, update_wrapper from datetime import timedelta from djadhere.utils import get_active_filter from adhesions.models import Adhesion from .models import Service, ServiceType, IPPrefix, IPResource, Route, Tunnel, ServiceAllocation, Antenna, AntennaAllocation, Allocation from .utils import notify_allocation from .forms import AntennaForm ### Filters class ResourceInUseFilter(admin.SimpleListFilter): title = 'disponibilité' parameter_name = 'available' def lookups(self, request, model_admin): return ( (1, 'Disponible'), (0, 'Non disponible'), ) def queryset(self, request, queryset): available_filter = Q(reserved=False, in_use=False) if self.value() == '0': # non disponible return queryset.exclude(available_filter) if self.value() == '1': # disponible return queryset.filter(available_filter) class ResourcePingFilter(admin.SimpleListFilter): title = 'ping' parameter_name = 'ping' def lookups(self, request, model_admin): return ( ('up', 'Up'), ('down', 'Down'), ('unknown', 'Inconnu'), ) def queryset(self, request, queryset): known_filter = models.Q(last_time_up__isnull=False, last_check__isnull=False) if self.value() == 'unknown': return queryset.exclude(known_filter) else: queryset = queryset.filter(known_filter) if self.value() == 'up': return queryset.filter(last_check__lte=models.F('last_time_up')) if self.value() == 'down': return queryset.filter(last_check__gt=models.F('last_time_up')) class ActiveServiceFilter(admin.SimpleListFilter): title = 'actif' parameter_name = 'active' def lookups(self, request, model_admin): return ( (1, 'Actif'), (0, 'Inactif'), ) def queryset(self, request, queryset): if self.value() == '0': # inactif return queryset.filter(has_active_allocations=False) if self.value() == '1': # actif return queryset.filter(has_active_allocations=True) class RouteFilter(admin.SimpleListFilter): title = 'route' parameter_name = 'route' def lookups(self, request, model_admin): return ServiceAllocation.objects.filter(active=True).values_list('route__pk', 'route__name').distinct() def queryset(self, request, queryset): try: route = int(self.value()) except (TypeError, ValueError): pass else: allocations = ServiceAllocation.objects.filter(active=True, route__pk=route).values_list('pk', flat=True) queryset = queryset.filter(service_allocation__in=allocations) return queryset class AntennaPrefixFilter(admin.SimpleListFilter): title = 'préfix' parameter_name = 'prefix' def lookups(self, request, model_admin): resources = AntennaAllocation.objects.filter(active=True).values_list('resource__pk', flat=True) prefixes = IPPrefix.objects.filter(ipresource__in=resources).values_list('pk', 'prefix').distinct() return prefixes def queryset(self, request, queryset): try: prefix = int(self.value()) except (TypeError, ValueError): pass else: allocations = AntennaAllocation.objects.filter(active=True, resource__prefixes__pk=prefix).values_list('pk', flat=True) queryset = queryset.filter(allocation__in=allocations) return queryset class AntennaPositionFilter(admin.SimpleListFilter): title = 'géolocalisation' parameter_name = 'position' def lookups(self, request, model_admin): return ( ('1', 'Connue'), ('0', 'Inconnue'), ) def queryset(self, request, queryset): query = Q(position__isnull=True) if self.value() == '0': return queryset.filter(query) if self.value() == '1': return queryset.exclude(query) return queryset class ActiveTunnelFilter(admin.SimpleListFilter): title = 'status' parameter_name = 'active' def lookups(self, request, model_admin): return ( ('1', 'Actif'), ('0', 'Désactivé'), ) def queryset(self, request, queryset): query = Q(ended__isnull=True) if self.value() == '0': return queryset.exclude(query) if self.value() == '1': return queryset.filter(query) return queryset ### Inlines class AllocationInlineFormSet(BaseInlineFormSet): def save_new(self, form, commit=True): obj = super().save_new(form, commit) if type(obj) == ServiceAllocation: notify_allocation(self.request, obj) return obj def save_existing(self, form, instance, commit=True): old = type(instance).objects.get(pk=instance.pk) if type(instance) == ServiceAllocation: notify_allocation(self.request, instance, old) return super().save_existing(form, instance, commit) class AllocationInline(admin.TabularInline): formset = AllocationInlineFormSet extra = 0 verbose_name_plural = 'Allocations' show_change_link = True def get_formset(self, request, obj=None, **kwargs): formset = super().get_formset(request, obj, **kwargs) formset.request = request return formset def get_max_num(self, request, obj=None, **kwargs): existing = obj.allocations.count() if obj else 0 # pour simplifier la validation, on ajoute qu’une allocation à la fois # il faudrait surcharger la méthode clean du formset pour supprimer cette limite return existing + 1 def has_delete_permission(self, request, obj=None): return False class ServiceAllocationInline(AllocationInline): model = ServiceAllocation fields = ('id', 'service', 'resource', 'route', 'start', 'end') raw_id_fields = ('service', 'resource',) class AntennaAllocationInline(AllocationInline): model = AntennaAllocation fields = ('id', 'antenna', 'resource', 'start', 'end') raw_id_fields = ('antenna', 'resource',) ### Actions def ends_resource(resource, request, queryset): now = timezone.now() queryset.exclude(start__lte=now, end__isnull=False).update(end=now) # TODO: send mail ends_resource.short_description = 'Terminer les allocations sélectionnées' ### ModelAdmin class ServiceAdmin(admin.ModelAdmin): list_display = ('id', 'get_adhesion_link', 'get_adherent_link', 'service_type', 'label', 'is_active',) list_select_related = ('adhesion', 'adhesion__user', 'adhesion__user__profile', 'adhesion__corporation', 'service_type') list_filter = ( ActiveServiceFilter, ('service_type', admin.RelatedOnlyFieldListFilter), ) inlines = (ServiceAllocationInline,) search_fields = ('=id', 'service_type__name', 'label', 'notes',) fields = ('adhesion', 'service_type', 'label', 'notes', 'get_contribution_link', 'is_active',) readonly_fields = ('get_contribution_link', 'is_active',) raw_id_fields = ('adhesion',) get_adhesion_link = lambda self, service: service.adhesion.get_adhesion_link() get_adhesion_link.short_description = Adhesion.get_adhesion_link.short_description get_adherent_link = lambda self, service: service.adhesion.get_adherent_link() get_adherent_link.short_description = Adhesion.get_adherent_link.short_description def get_contribution_link(self, obj): return format_html(u'{}', obj.contribution.get_absolute_url(), obj.contribution) get_contribution_link.short_description = 'Contribution financière' def get_actions(self, request): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] return actions def has_delete_permission(self, request, obj=None): return False class IPPrefixAdmin(admin.ModelAdmin): readonly_fields = ('prefix',) def has_delete_permission(self, request, obj=None): # Interdiction de supprimer le préfix s’il est assigné à une route return obj and obj.tunnel_set.exists() # pour embêcher de by-passer le check has_delete_permission, on désactive l’action delete def get_actions(self, request): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] return actions class IPResourceAdmin(admin.ModelAdmin): list_display = ('__str__', 'available_display', 'last_use', 'ping',) list_filter = ( 'category', ResourceInUseFilter, ResourcePingFilter, 'reserved', ('prefixes', admin.RelatedOnlyFieldListFilter), RouteFilter, ) search_fields = ('=ip', 'notes',) def get_fields(self, request, obj=None): return self.get_readonly_fields(request, obj) + ['notes'] def get_readonly_fields(self, request, obj=None): fields = ['ip'] if obj and obj.reserved: fields += ['reserved'] if obj and not obj.in_use: fields += ['last_use'] if obj and obj.last_time_up and obj.last_check: fields += ['last_time_up', 'last_check'] if obj and obj.checkmk_label: fields += ['checkmk'] return fields def get_inline_instances(self, request, obj=None): if obj: if obj.category == 0: inlines = (ServiceAllocationInline,) elif obj.category == 1: inlines = (AntennaAllocationInline,) else: inlines = () return [inline(self.model, self.admin_site) for inline in inlines] def get_queryset(self, request): qs = super().get_queryset(request) qs = qs.annotate(last_use=models.Case( models.When(category=0, then=models.Max('service_allocation__end')), models.When(category=1, then=models.Max('antenna_allocation__end')), default=None, )) qs = qs.annotate(downtime=Cast( models.Case( models.When(last_check__isnull=False, last_time_up__isnull=False, last_check__lte=models.F('last_time_up'), then=timedelta(days=0)), models.When(last_check__isnull=False, last_time_up__isnull=False, then=models.F('last_check') - models.F('last_time_up')), default=None, ), models.DurationField(), )) return qs def available_display(self, obj): return not obj.reserved and not obj.in_use available_display.short_description = 'Disponible' available_display.boolean = True def last_use(self, obj): if obj.last_use: return naturaltime(obj.last_use) else: return '-' last_use.short_description = 'Dernière utilisation' last_use.admin_order_field = 'last_use' def ping(self, obj): if obj.downtime: label = 'down depuis >' + str(obj.downtime) elif obj.downtime == timedelta(days=0): label = 'UP' else: return None if obj.checkmk_url: return format_html('{}', obj.checkmk_url, label) else: return label ping.short_description = 'ping' ping.admin_order_field = 'downtime' def checkmk(self, obj): return format_html('{}', obj.checkmk_url, 'voir') checkmk.short_description = 'CheckMK' def get_actions(self, request): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] return actions def has_add_permission(self, request, obj=None): return False def has_delete_permission(self, request, obj=None): return False class RouteAdmin(admin.ModelAdmin): list_display = ('name',) def get_fields(self, request, obj=None): if obj: return ('name', 'get_emails', 'get_sms', 'get_routed_ip', 'get_adh',) else: return ('name',) def get_readonly_fields(self, request, obj=None): if obj: return ('get_emails', 'get_sms', 'get_routed_ip', 'get_adh',) else: return () def get_contacts(self, route): cache_emails_key = 'route-%d-emails' % route.pk cache_sms_key = 'route-%d-sms' % route.pk cache_adh_key = 'route-%d-adh' % route.pk cache_results = cache.get_many([cache_emails_key, cache_sms_key, cache_adh_key]) if len(cache_results) == 3: return (cache_results[cache_emails_key], cache_results[cache_sms_key], cache_results[cache_adh_key]) allocations = route.allocations \ .order_by('service__adhesion__pk') \ .distinct('service__adhesion__pk') \ .select_related( 'service__adhesion__user', 'service__adhesion__corporation', ) emails, sms, adh = [], [], [] for allocation in allocations: adhesion = allocation.service.adhesion adherent = adhesion.adherent if adhesion.pk not in adh: adh.append(adhesion.pk) if adherent.email: email = '%s <%s>' % (str(adherent), adherent.email) if email not in emails: emails.append(email) # S’il s’agit d’une raison sociale, on contact aussi les gestionnaires if adhesion.corporation: if adhesion.corporation.phone_number: sms.append(adhesion.corporation.phone_number) for member in adhesion.corporation.members.all(): if member.email: email = '%s <%s>' % (str(member), member.email) if email not in emails: emails.append(email) else: # user if adhesion.user.profile.phone_number: if adhesion.user.profile.phone_number not in sms: sms.append(adhesion.user.profile.phone_number) sms = list(filter(lambda x: x[:2] == '06' or x[:2] == '07' or x[:3] == '+336' or x[:3] == '+337', sms)) cache.set_many({cache_emails_key: emails, cache_sms_key: sms, cache_adh_key: adh}, timeout=3600) return (emails, sms, adh) def get_emails(self, route): emails, _, _ = self.get_contacts(route) return '\n'.join(emails) get_emails.short_description = 'Contacts' def get_sms(self, route): _, sms, _ = self.get_contacts(route) return '\n'.join(sms) get_sms.short_description = 'SMS' def get_adh(self, route): _, _, adh = self.get_contacts(route) return '\n'.join(map(lambda a: 'ADT%d' % a, sorted(adh))) get_adh.short_description = 'Adhérents' def get_routed_ip(self, route): routed_ip = route.allocations.order_by('resource__ip').values_list('resource__ip', flat=True) return '\n'.join(routed_ip) get_routed_ip.short_description = 'IP routées' def get_actions(self, request): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] return actions def has_delete_permission(self, request, obj=None): return False class TunnelAdmin(admin.ModelAdmin): list_display = ('name', 'description', 'created', 'active') list_filter = ( ActiveTunnelFilter, ) def active(self, obj): return not obj.ended active.short_description = 'Actif' active.boolean = True class ServiceTypeAdmin(admin.ModelAdmin): fields = ('name',) def get_actions(self, request): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] return actions def has_delete_permission(self, request, obj=None): return False class AntennaAdmin(admin.ModelAdmin): inlines = (AntennaAllocationInline,) list_filter = ( AntennaPrefixFilter, AntennaPositionFilter, 'mode', 'ssid', ) list_display_links = ('id', 'label') search_fields = ('=id', 'label', 'notes', 'ssid') raw_id_fields = ('contact',) form = AntennaForm def get_queryset(self, request): qs = super().get_queryset(request) if connection.vendor == 'postgresql': qs = qs.annotate( ip=StringAgg( # concaténation des IP avec des virgules directement par postgresql Cast( # casting en TextField car StringApp oppère sur des string mais les ip sont des inet models.Case( # seulement les IP des allocations actives models.When( get_active_filter('allocation'), then='allocation__resource__ip' ), ), models.TextField() ), delimiter=', ' ) ) return qs def get_list_display(self, request): # ssid_display needs request to access query string and preserve filters ssid_display = partial(self.ssid_display, request=request) update_wrapper(ssid_display, self.ssid_display) return ('id', 'label', 'mode', ssid_display, 'position_display', 'ip_display') def position_display(self, obj): return obj.position is not None position_display.short_description = 'Géolocalisé' position_display.boolean = True def ip_display(self, obj): if connection.vendor == 'postgresql': return obj.ip else: # peu efficace car génère une requête par ligne allocations = obj.allocations.filter(active=True) return ', '.join(allocations.values_list('resource__ip', flat=True)) or '-' ip_display.short_description = 'IP' def ssid_display(self, obj, request): if obj.ssid: qs = request.GET.copy() qs.update({'ssid': obj.ssid}) ssid_url = reverse('admin:services_antenna_changelist') + '?' + urlencode(qs) return format_html(u'{}', ssid_url, obj.ssid) else: return None ssid_display.short_description = 'SSID' def get_actions(self, request): actions = super().get_actions(request) if 'delete_selected' in actions: del actions['delete_selected'] return actions def has_delete_permission(self, request, obj=None): return False def get_urls(self): my_urls = [ url(r'^map/$', self.admin_site.admin_view(self.map_view, cacheable=True), name='antenna-map'), url(r'^map/data.json$', self.admin_site.admin_view(GeoJSONLayerView.as_view( model=Antenna, geometry_field='position', properties=('label', 'mode', 'ssid', 'orientation',), )), name='antenna-map-data'), ] return my_urls + super().get_urls() def map_view(self, request): return TemplateResponse(request, 'services/antenna_map.html', { 'json_url': reverse('admin:antenna-map-data'), }) def map_data_view(self, request): geojson = serialize('geojson', Antenna.objects.all(), geometry_field='point', fields=('position',)) return HttpResponse(geojson, content_type='application/json') admin.site.register(ServiceType, ServiceTypeAdmin) admin.site.register(Service, ServiceAdmin) admin.site.register(IPPrefix, IPPrefixAdmin) admin.site.register(IPResource, IPResourceAdmin) admin.site.register(Route, RouteAdmin) admin.site.register(Tunnel, TunnelAdmin) admin.site.register(Antenna, AntennaAdmin)