diff --git a/CHANGELOG b/CHANGELOG index 8c57f4f..74afcec 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,6 @@ +20-07-2023 (AKo): V0.6.27.0 + - Add command to download observatory repository + 17-07-2023 (AKo): V0.6.27.0 - Change obsconfig folders & agents names + location & support CNES SSI Docker requirements diff --git a/src/core/pyros_django/dashboard/admin.py b/src/core/pyros_django/dashboard/admin.py index 8c38f3f..d78acd5 100644 --- a/src/core/pyros_django/dashboard/admin.py +++ b/src/core/pyros_django/dashboard/admin.py @@ -1,3 +1,296 @@ + +# Django imports +from django import forms from django.contrib import admin +from django.contrib.auth.models import User +# EP +from django.conf import settings + +# Project imports +from user_mgmt.models import Country, Institute, PyrosUser, UserLevel, ScientificProgram +from common.models import * +from majordome.models import * +from env_monitor.models import * +from devices.models import Detector, Filter, AgentDeviceStatus, FilterWheel, Telescope, PlcDevice, PlcDeviceStatus +from seq_submit.models import Image, Schedule, Sequence, Album, Plan, ScheduleHasSequences #, StrategyObs, NrtAnalysis +#from seq_submit.models import Image, StrategyObs, Schedule, Request, Alert, Sequence, Album, Plan, NrtAnalysis, ScheduleHasSequences + + +# EP added +class ReadOnlyModelAdmin(admin.ModelAdmin): + """ModelAdmin class that prevents modifications through the admin. + The changelist and the detail view work, but a 403 is returned + if one actually tries to edit an object. + Source: https://gist.github.com/aaugustin/1388243 + """ + + actions = None + + def get_readonly_fields(self, request, obj=None): + return self.fields or [f.name for f in self.model._meta.fields] + + def has_add_permission(self, request): + return False + + # Allow viewing objects but not actually changing them + def has_change_permission(self, request, obj=None): + if request.method not in ('GET', 'HEAD'): + return False + return super(ReadOnlyModelAdmin, self).has_change_permission(request, obj) + + def has_delete_permission(self, request, obj=None): + return False + + +# EP added + +# Edit mode +# DEBUG = False +# View only mode +# DEBUG = True + +''' Uncomment for production ''' + +# if settings.DEBUG: +# class PyrosModelAdmin(ReadOnlyModelAdmin): +# pass +# else: +# class PyrosModelAdmin(admin.ModelAdmin): +# pass + + +class PyrosModelAdmin(admin.ModelAdmin): + pass + +# Many To Many interface adapter + + +class PyrosUserAndSPInline(admin.TabularInline): + #model = ScientificProgram.pyros_users.through + pass + + +class SequenceAndScheduleInline(admin.TabularInline): + model = Schedule.sequences.through + +class PyrosUserAndUserLevelInline(admin.TabularInline): + # add admin representation for m2m relation between PyrosUser and UserLevel + model = UserLevel.pyros_users.through + +class ScheduleAdmin(admin.ModelAdmin): + inlines = [ + SequenceAndScheduleInline, + ] + exclude = ('sequences',) + + +# One To Many interface adapters + +class SequenceInline(admin.TabularInline): + model = Sequence + readonly_fields = ("name",) + fields = ("name",) + show_change_link = True + + +# class RequestInline(admin.TabularInline): +# model = Request +# readonly_fields = ("name",) +# fields = ("name",) +# show_change_link = True + + +class AlbumInline(admin.TabularInline): + model = Album + readonly_fields = ("name",) + fields = ("name",) + show_change_link = True + + +class PlanInline(admin.TabularInline): + model = Plan + #readonly_fields = ("name",) + #fields = ("name",) + show_change_link = True + + +class ImageInline(admin.TabularInline): + model = Image + readonly_fields = ("name",) + fields = ("name",) + show_change_link = True + + +class DetectorInline(admin.TabularInline): + model = Detector + readonly_fields = ("device_name",) + fields = ("device_name",) + show_change_link = True + + +class PyrosUserInline(admin.TabularInline): + model = PyrosUser + readonly_fields = ("user_username",) + fields = ("user_username",) + show_change_link = True + + +class FilterInline(admin.TabularInline): + model = Filter + readonly_fields = ("device_name",) + fields = ("device_name",) + show_change_link = True + + +# class AlertInline(admin.TabularInline): +# model = Alert +# readonly_fields = ("request_name",) +# fields = ("request_name",) +# show_change_link = True + + +# Admin model classes + +# class RequestAdmin(PyrosModelAdmin): +# pass + # inlines = [ + # SequenceInline, + # ] + + +class SequenceAdmin(PyrosModelAdmin): + inlines = [ + AlbumInline, + SequenceAndScheduleInline, # for M2M interface + ] + + +class PyrosUserAdmin(PyrosModelAdmin): + list_display = ("user_username","is_active","laboratory") + list_filter = ("is_active",) + list_editable = ("is_active",) + inlines = [ + #RequestInline, + # A user has many SPs +# PyrosUserAndSPInline, # for M2M interface + ] + + +''' +class StrategyObsAdmin(PyrosModelAdmin): + inlines = [ + #AlertInline, + ] +''' + + +class ScientificProgramAdmin(PyrosModelAdmin): + inlines = [ + #RequestInline, + # A SP has many users: + # PyrosUserAndSPInline, # for M2M interface + ] + exclude = ('pyros_users',) # for M2M interface + + +class CountryAdmin(PyrosModelAdmin): + inlines = [ + PyrosUserInline, + ] + + +class UserLevelAdmin(PyrosModelAdmin): + inlines = [ + #PyrosUserInline, + PyrosUserAndUserLevelInline, + ] + list_display = ("name","priority",) + # we need to exclude pyros_users which represents the m2m relation between UserLevel and PyrosUser + exclude = ("pyros_users",) + + +class FilterAdmin(PyrosModelAdmin): + # inlines = [ + # PlanInline, + # ] + pass + + +class FilterWheelAdmin(PyrosModelAdmin): + inlines = [ + FilterInline, + ] + + +''' +class NrtAnalysisAdmin(PyrosModelAdmin): + inlines = [ + ImageInline, + ] +''' + + +class DetectorAdmin(PyrosModelAdmin): + pass + # inlines = [ + # AlbumInline, + # ] + + +class TelescopeAdmin(PyrosModelAdmin): + inlines = [ + DetectorInline, + ] + + +class PlanAdmin(PyrosModelAdmin): + inlines = [ + ImageInline, + ] + + +# class AlbumAdmin(admin.ModelAdmin): +class AlbumAdmin(PyrosModelAdmin): + inlines = [ + PlanInline, + ] + + +# Link the models to the admin interface + +# (EP added 10/7/19) +admin.site.register(AgentCmd) +admin.site.register(AgentLogs) +admin.site.register(AgentSurvey) +admin.site.register(AgentDeviceStatus) + -# Register your models here. +admin.site.register(Album, AlbumAdmin) +#admin.site.register(Alert) +admin.site.register(Country, CountryAdmin) +admin.site.register(Detector, DetectorAdmin) +admin.site.register(Filter, FilterAdmin) +admin.site.register(FilterWheel, FilterWheelAdmin) +admin.site.register(Image) +admin.site.register(Log) +#admin.site.register(NrtAnalysis, NrtAnalysisAdmin) +admin.site.register(Plan, PlanAdmin) +#admin.site.register(Request, RequestAdmin) +admin.site.register(Schedule, ScheduleAdmin) +admin.site.register(ScheduleHasSequences) +admin.site.register(ScientificProgram, ScientificProgramAdmin) +admin.site.register(Sequence, SequenceAdmin) +admin.site.register(SiteWatch) +admin.site.register(SiteWatchHistory) +#admin.site.register(StrategyObs, StrategyObsAdmin) +##admin.site.register(TaskId) +admin.site.register(Telescope, TelescopeAdmin) +admin.site.register(PyrosUser, PyrosUserAdmin) +admin.site.register(UserLevel, UserLevelAdmin) +admin.site.register(Version) +admin.site.register(WeatherWatch) +admin.site.register(WeatherWatchHistory) +admin.site.register(PlcDeviceStatus) +admin.site.register(PlcDevice) +admin.site.register(Config) +admin.site.register(Institute) \ No newline at end of file diff --git a/src/core/pyros_django/dashboard/models.py b/src/core/pyros_django/dashboard/models.py index 71a8362..422742e 100644 --- a/src/core/pyros_django/dashboard/models.py +++ b/src/core/pyros_django/dashboard/models.py @@ -1,3 +1,332 @@ +##from __future__ import unicode_literals + +# (EP 21/9/22) To allow autoreferencing (ex: AgentCmd.create() returns a AgentCmd) +from __future__ import annotations + +# Stdlib imports +from src.device_controller.abstract_component.device_controller import DeviceCmd +from datetime import datetime, timedelta, date +from dateutil.relativedelta import relativedelta +import os +import sys +from typing import Any, List, Tuple, Optional +import re + +# Django imports +from django.core.validators import MaxValueValidator, MinValueValidator + +# DJANGO imports +from django.contrib.auth.models import AbstractUser, UserManager from django.db import models +from django.db.models import Q, Max +from django.core.validators import MaxValueValidator, MinValueValidator +#from django.db.models.deletion import DO_NOTHING +from django.db.models.expressions import F +from django.db.models.query import QuerySet +from model_utils import Choices +from django.utils import timezone +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.db.models.signals import post_save +from django.dispatch import receiver +# Project imports +from user_mgmt.models import PyrosUser +# DeviceCommand is used by class Command +sys.path.append("../../..") + + + + +""" +STYLE RULES +=========== +https://simpleisbetterthancomplex.com/tips/2018/02/10/django-tip-22-designing-better-models.html +https://steelkiwi.com/blog/best-practices-working-django-models-python/ + +- Model name => singular + Call it Company instead of Companies. + A model definition is the representation of a single object (the object in this example is a company), + and not a collection of companies + The model definition is a class, so always use CapWords convention (no underscores) + E.g. User, Permission, ContentType, etc. + +- For the model’s attributes use snake_case. + E.g. first_name, last_name, etc + +- Blank and Null Fields (https://simpleisbetterthancomplex.com/tips/2016/07/25/django-tip-8-blank-or-null.html) + - Null: It is database-related. Defines if a given database column will accept null values or not. + - Blank: It is validation-related. It will be used during forms validation, when calling form.is_valid(). + Do not use null=True for text-based fields that are optional. + Otherwise, you will end up having two possible values for “no data”, that is: None and an empty string. + Having two possible values for “no data” is redundant. + The Django convention is to use the empty string, not NULL. + Example: + # The default values of `null` and `blank` are `False`. + class Person(models.Model): + name = models.CharField(max_length=255) # Mandatory + bio = models.TextField(max_length=500, blank=True) # Optional (don't put null=True) + birth_date = models.DateField(null=True, blank=True) # Optional (here you may add null=True) + The default values of null and blank are False. + Special case, when you need to accept NULL values for a BooleanField, use NullBooleanField instead. + +- Choices : you can use Choices from the model_utils library. Take model Article, for instance: + from model_utils import Choices + class Article(models.Model): + STATUSES = Choices( + (0, 'draft', _('draft')), + (1, 'published', _('published')) ) + status = models.IntegerField(choices=STATUSES, default=STATUSES.draft) + +- Reverse Relationships + + - related_name : + Rule of thumb: if you are not sure what would be the related_name, + use the plural of the model holding the ForeignKey. + ex: + class Company: + name = models.CharField(max_length=30) + class Employee: + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=30) + company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='employees') + usage: + google = Company.objects.get(name='Google') + google.employees.all() + You can also use the reverse relationship to modify the company field on the Employee instances: + vitor = Employee.objects.get(first_name='Vitor') + google = Company.objects.get(name='Google') + google.employees.add(vitor) + + - related_query_name : + This kind of relationship also applies to query filters. + For example, if I wanted to list all companies that employs people named ‘Vitor’, I could do the following: + companies = Company.objects.filter(employee__first_name='Vitor') + If you want to customize the name of this relationship, here is how we do it: + class Employee: + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=30) + company = models.ForeignKey( + Company, + on_delete=models.CASCADE, + related_name='employees', + related_query_name='person' + ) + Then the usage would be: + companies = Company.objects.filter(person__first_name='Vitor') + + To use it consistently, related_name goes as plural and related_query_name goes as singular. + + +GENERAL EXAMPLE +======= + +from django.db import models +from django.urls import reverse + +class Company(models.Model): + # CHOICES + PUBLIC_LIMITED_COMPANY = 'PLC' + PRIVATE_COMPANY_LIMITED = 'LTD' + LIMITED_LIABILITY_PARTNERSHIP = 'LLP' + COMPANY_TYPE_CHOICES = ( + (PUBLIC_LIMITED_COMPANY, 'Public limited company'), + (PRIVATE_COMPANY_LIMITED, 'Private company limited by shares'), + (LIMITED_LIABILITY_PARTNERSHIP, 'Limited liability partnership'), + ) + + # DATABASE FIELDS + name = models.CharField('name', max_length=30) + vat_identification_number = models.CharField('VAT', max_length=20) + company_type = models.CharField('type', max_length=3, choices=COMPANY_TYPE_CHOICES) + + # MANAGERS + objects = models.Manager() + limited_companies = LimitedCompanyManager() + + # META CLASS + class Meta: + verbose_name = 'company' + verbose_name_plural = 'companies' + + # TO STRING METHOD + def __str__(self): + return self.name + + # SAVE METHOD + def save(self, *args, **kwargs): + do_something() + super().save(*args, **kwargs) # Call the "real" save() method. + do_something_else() + + # ABSOLUTE URL METHOD + def get_absolute_url(self): + return reverse('company_details', kwargs={'pk': self.id}) + + # OTHER METHODS + def process_invoices(self): + do_something() + +""" + + +# --- +# --- Utility functions +# --- + +def printd(*args, **kwargs): + if os.environ.get('PYROS_DEBUG', '0') == '1': + print('(MODEL)', *args, **kwargs) + +''' +def get_or_create_unique_row_from_model(model: models.Model): + # return model.objects.get(id=1) if model.objects.exists() else model.objects.create(id=1) + return model.objects.first() if model.objects.exists() else model.objects.create(id=1) +''' + + + + + +""" +------------------------ + BASE MODEL CLASSES +------------------------ +""" + +""" +------------------------ + OTHER MODEL CLASSES +------------------------ +""" + + +# TODO: OLD Config : à virer (mais utilisé dans dashboard/templatetags/tags.py) +class Config(models.Model): + PYROS_STATE = ["Starting", "Passive", "Standby", + "Remote", "Startup", "Scheduler", "Closing"] + + id = models.IntegerField(default='1', primary_key=True) + #latitude = models.FloatField(default=1) + latitude = models.DecimalField( + max_digits=4, decimal_places=2, + default=1, + validators=[ + MaxValueValidator(90), + MinValueValidator(-90) + ] + ) + local_time_zone = models.FloatField(default=1) + #longitude = models.FloatField(default=1) + longitude = models.DecimalField( + max_digits=5, decimal_places=2, + default=1, + validators=[ + MaxValueValidator(360), + MinValueValidator(-360) + ] + ) + altitude = models.FloatField(default=1) + horizon_line = models.FloatField(default=1) + row_data_save_frequency = models.IntegerField(default='300') + request_frequency = models.IntegerField(default='300') + analysed_data_save = models.IntegerField(default='300') + telescope_ip_address = models.CharField(max_length=45, default="127.0.0.1") + camera_ip_address = models.CharField(max_length=45, default="127.0.0.1") + plc_ip_address = models.CharField(max_length=45, default="127.0.0.1") + + # TODO: changer ça, c'est pas clair du tout... + # True = mode Scheduler-standby, False = mode Remote !!!! + global_mode = models.BooleanField(default='True') + + ack = models.BooleanField(default='False') + bypass = models.BooleanField(default='True') + lock = models.BooleanField(default='False') + pyros_state = models.CharField(max_length=25, default=PYROS_STATE[0]) + force_passive_mode = models.BooleanField(default='False') + plc_timeout_seconds = models.PositiveIntegerField(default=60) + majordome_state = models.CharField(max_length=25, default="") + ntc = models.BooleanField(default='False') + majordome_restarted = models.BooleanField(default='False') + + class Meta: + managed = True + db_table = 'config' + verbose_name_plural = "Config" + + def __str__(self): + return (str(self.__dict__)) + + + +class Log(models.Model): + agent = models.CharField(max_length=45, blank=True, null=True) + created = models.DateTimeField(blank=True, null=True, auto_now_add=True) + message = models.TextField(blank=True, null=True) + + class Meta: + managed = True + db_table = 'log' + + def __str__(self): + return (str(self.agent)) + + +# TODO: à virer car utilisé pour Celery (ou bien à utiliser pour les agents) +''' +class TaskId(models.Model): + task = models.CharField(max_length=45, blank=True, null=True) + created = models.DateTimeField(blank=True, null=True, auto_now_add=True) + task_id = models.CharField(max_length=45, blank=True, null=True) + + class Meta: + managed = True + db_table = 'task_id' + + def __str__(self): + return (str(self.task) + " - " + str(self.task_id)) +''' + + +class Version(models.Model): + module_name = models.CharField(max_length=45, blank=True, null=True) + version = models.CharField(max_length=15, blank=True, null=True) + created = models.DateTimeField(blank=True, null=True, auto_now_add=True) + updated = models.DateTimeField(blank=True, null=True, auto_now=True) + + class Meta: + managed = True + db_table = 'version' + + def __str__(self): + return (str(self.module_name) + " - " + str(self.version)) + + +class Tickets(models.Model): + created = models.DateTimeField(blank=True, null=True, auto_now_add=True) + updated = models.DateTimeField(blank=True, null=True, auto_now=True) + end = models.DateTimeField(blank=True, null=True) + title = models.TextField(blank=True, null=True) + description = models.TextField(blank=True, null=True) + resolution = models.TextField(blank=True, null=True) + pyros_user = models.ForeignKey(PyrosUser, on_delete=models.DO_NOTHING, related_name="tickets", blank=True, null=True) + last_modified_by = models.ForeignKey(PyrosUser, on_delete=models.DO_NOTHING, related_name="tickets_modified_by", blank=True, null=True) + LEVEL_ONE = "ONE" + LEVEL_TWO = "TWO" + LEVEL_THREE = "THREE" + LEVEL_FOUR = "FOUR" + LEVEL_FIVE = "FIVE" + SECURITY_LEVEL_CHOICES = ( + (LEVEL_ONE,"Warning non compromising for the operation of the system"), + (LEVEL_TWO,"Known issue which can be solved by operating the software remotely"), + (LEVEL_THREE,"Known issue which can be solved by an human remotely"), + (LEVEL_FOUR,"Known issue without immediate solution"), + (LEVEL_FIVE,"Issue not categorized until it happened") + ) + security_level = models.TextField(choices=SECURITY_LEVEL_CHOICES, default=LEVEL_ONE) + + class Meta: + managed = True + db_table = 'tickets' + verbose_name_plural = "tickets" + -# Create your models here. -- libgit2 0.21.2