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)