Commit 9d12be5886380dfcbdc0e2859eac1cfae04fdd78

Authored by Alexis Koralewski
1 parent 03c5ff87
Exists in dev

transfer common admins & models into dashboard

CHANGELOG
  1 +20-07-2023 (AKo): V0.6.27.0
  2 + - Add command to download observatory repository
  3 +
1 4 17-07-2023 (AKo): V0.6.27.0
2 5 - Change obsconfig folders & agents names + location & support CNES SSI Docker requirements
3 6  
... ...
src/core/pyros_django/dashboard/admin.py
  1 +
  2 +# Django imports
  3 +from django import forms
1 4 from django.contrib import admin
  5 +from django.contrib.auth.models import User
  6 +# EP
  7 +from django.conf import settings
  8 +
  9 +# Project imports
  10 +from user_mgmt.models import Country, Institute, PyrosUser, UserLevel, ScientificProgram
  11 +from common.models import *
  12 +from majordome.models import *
  13 +from env_monitor.models import *
  14 +from devices.models import Detector, Filter, AgentDeviceStatus, FilterWheel, Telescope, PlcDevice, PlcDeviceStatus
  15 +from seq_submit.models import Image, Schedule, Sequence, Album, Plan, ScheduleHasSequences #, StrategyObs, NrtAnalysis
  16 +#from seq_submit.models import Image, StrategyObs, Schedule, Request, Alert, Sequence, Album, Plan, NrtAnalysis, ScheduleHasSequences
  17 +
  18 +
  19 +# EP added
  20 +class ReadOnlyModelAdmin(admin.ModelAdmin):
  21 + """ModelAdmin class that prevents modifications through the admin.
  22 + The changelist and the detail view work, but a 403 is returned
  23 + if one actually tries to edit an object.
  24 + Source: https://gist.github.com/aaugustin/1388243
  25 + """
  26 +
  27 + actions = None
  28 +
  29 + def get_readonly_fields(self, request, obj=None):
  30 + return self.fields or [f.name for f in self.model._meta.fields]
  31 +
  32 + def has_add_permission(self, request):
  33 + return False
  34 +
  35 + # Allow viewing objects but not actually changing them
  36 + def has_change_permission(self, request, obj=None):
  37 + if request.method not in ('GET', 'HEAD'):
  38 + return False
  39 + return super(ReadOnlyModelAdmin, self).has_change_permission(request, obj)
  40 +
  41 + def has_delete_permission(self, request, obj=None):
  42 + return False
  43 +
  44 +
  45 +# EP added
  46 +
  47 +# Edit mode
  48 +# DEBUG = False
  49 +# View only mode
  50 +# DEBUG = True
  51 +
  52 +''' Uncomment for production '''
  53 +
  54 +# if settings.DEBUG:
  55 +# class PyrosModelAdmin(ReadOnlyModelAdmin):
  56 +# pass
  57 +# else:
  58 +# class PyrosModelAdmin(admin.ModelAdmin):
  59 +# pass
  60 +
  61 +
  62 +class PyrosModelAdmin(admin.ModelAdmin):
  63 + pass
  64 +
  65 +# Many To Many interface adapter
  66 +
  67 +
  68 +class PyrosUserAndSPInline(admin.TabularInline):
  69 + #model = ScientificProgram.pyros_users.through
  70 + pass
  71 +
  72 +
  73 +class SequenceAndScheduleInline(admin.TabularInline):
  74 + model = Schedule.sequences.through
  75 +
  76 +class PyrosUserAndUserLevelInline(admin.TabularInline):
  77 + # add admin representation for m2m relation between PyrosUser and UserLevel
  78 + model = UserLevel.pyros_users.through
  79 +
  80 +class ScheduleAdmin(admin.ModelAdmin):
  81 + inlines = [
  82 + SequenceAndScheduleInline,
  83 + ]
  84 + exclude = ('sequences',)
  85 +
  86 +
  87 +# One To Many interface adapters
  88 +
  89 +class SequenceInline(admin.TabularInline):
  90 + model = Sequence
  91 + readonly_fields = ("name",)
  92 + fields = ("name",)
  93 + show_change_link = True
  94 +
  95 +
  96 +# class RequestInline(admin.TabularInline):
  97 +# model = Request
  98 +# readonly_fields = ("name",)
  99 +# fields = ("name",)
  100 +# show_change_link = True
  101 +
  102 +
  103 +class AlbumInline(admin.TabularInline):
  104 + model = Album
  105 + readonly_fields = ("name",)
  106 + fields = ("name",)
  107 + show_change_link = True
  108 +
  109 +
  110 +class PlanInline(admin.TabularInline):
  111 + model = Plan
  112 + #readonly_fields = ("name",)
  113 + #fields = ("name",)
  114 + show_change_link = True
  115 +
  116 +
  117 +class ImageInline(admin.TabularInline):
  118 + model = Image
  119 + readonly_fields = ("name",)
  120 + fields = ("name",)
  121 + show_change_link = True
  122 +
  123 +
  124 +class DetectorInline(admin.TabularInline):
  125 + model = Detector
  126 + readonly_fields = ("device_name",)
  127 + fields = ("device_name",)
  128 + show_change_link = True
  129 +
  130 +
  131 +class PyrosUserInline(admin.TabularInline):
  132 + model = PyrosUser
  133 + readonly_fields = ("user_username",)
  134 + fields = ("user_username",)
  135 + show_change_link = True
  136 +
  137 +
  138 +class FilterInline(admin.TabularInline):
  139 + model = Filter
  140 + readonly_fields = ("device_name",)
  141 + fields = ("device_name",)
  142 + show_change_link = True
  143 +
  144 +
  145 +# class AlertInline(admin.TabularInline):
  146 +# model = Alert
  147 +# readonly_fields = ("request_name",)
  148 +# fields = ("request_name",)
  149 +# show_change_link = True
  150 +
  151 +
  152 +# Admin model classes
  153 +
  154 +# class RequestAdmin(PyrosModelAdmin):
  155 +# pass
  156 + # inlines = [
  157 + # SequenceInline,
  158 + # ]
  159 +
  160 +
  161 +class SequenceAdmin(PyrosModelAdmin):
  162 + inlines = [
  163 + AlbumInline,
  164 + SequenceAndScheduleInline, # for M2M interface
  165 + ]
  166 +
  167 +
  168 +class PyrosUserAdmin(PyrosModelAdmin):
  169 + list_display = ("user_username","is_active","laboratory")
  170 + list_filter = ("is_active",)
  171 + list_editable = ("is_active",)
  172 + inlines = [
  173 + #RequestInline,
  174 + # A user has many SPs
  175 +# PyrosUserAndSPInline, # for M2M interface
  176 + ]
  177 +
  178 +
  179 +'''
  180 +class StrategyObsAdmin(PyrosModelAdmin):
  181 + inlines = [
  182 + #AlertInline,
  183 + ]
  184 +'''
  185 +
  186 +
  187 +class ScientificProgramAdmin(PyrosModelAdmin):
  188 + inlines = [
  189 + #RequestInline,
  190 + # A SP has many users:
  191 + # PyrosUserAndSPInline, # for M2M interface
  192 + ]
  193 + exclude = ('pyros_users',) # for M2M interface
  194 +
  195 +
  196 +class CountryAdmin(PyrosModelAdmin):
  197 + inlines = [
  198 + PyrosUserInline,
  199 + ]
  200 +
  201 +
  202 +class UserLevelAdmin(PyrosModelAdmin):
  203 + inlines = [
  204 + #PyrosUserInline,
  205 + PyrosUserAndUserLevelInline,
  206 + ]
  207 + list_display = ("name","priority",)
  208 + # we need to exclude pyros_users which represents the m2m relation between UserLevel and PyrosUser
  209 + exclude = ("pyros_users",)
  210 +
  211 +
  212 +class FilterAdmin(PyrosModelAdmin):
  213 + # inlines = [
  214 + # PlanInline,
  215 + # ]
  216 + pass
  217 +
  218 +
  219 +class FilterWheelAdmin(PyrosModelAdmin):
  220 + inlines = [
  221 + FilterInline,
  222 + ]
  223 +
  224 +
  225 +'''
  226 +class NrtAnalysisAdmin(PyrosModelAdmin):
  227 + inlines = [
  228 + ImageInline,
  229 + ]
  230 +'''
  231 +
  232 +
  233 +class DetectorAdmin(PyrosModelAdmin):
  234 + pass
  235 + # inlines = [
  236 + # AlbumInline,
  237 + # ]
  238 +
  239 +
  240 +class TelescopeAdmin(PyrosModelAdmin):
  241 + inlines = [
  242 + DetectorInline,
  243 + ]
  244 +
  245 +
  246 +class PlanAdmin(PyrosModelAdmin):
  247 + inlines = [
  248 + ImageInline,
  249 + ]
  250 +
  251 +
  252 +# class AlbumAdmin(admin.ModelAdmin):
  253 +class AlbumAdmin(PyrosModelAdmin):
  254 + inlines = [
  255 + PlanInline,
  256 + ]
  257 +
  258 +
  259 +# Link the models to the admin interface
  260 +
  261 +# (EP added 10/7/19)
  262 +admin.site.register(AgentCmd)
  263 +admin.site.register(AgentLogs)
  264 +admin.site.register(AgentSurvey)
  265 +admin.site.register(AgentDeviceStatus)
  266 +
2 267  
3   -# Register your models here.
  268 +admin.site.register(Album, AlbumAdmin)
  269 +#admin.site.register(Alert)
  270 +admin.site.register(Country, CountryAdmin)
  271 +admin.site.register(Detector, DetectorAdmin)
  272 +admin.site.register(Filter, FilterAdmin)
  273 +admin.site.register(FilterWheel, FilterWheelAdmin)
  274 +admin.site.register(Image)
  275 +admin.site.register(Log)
  276 +#admin.site.register(NrtAnalysis, NrtAnalysisAdmin)
  277 +admin.site.register(Plan, PlanAdmin)
  278 +#admin.site.register(Request, RequestAdmin)
  279 +admin.site.register(Schedule, ScheduleAdmin)
  280 +admin.site.register(ScheduleHasSequences)
  281 +admin.site.register(ScientificProgram, ScientificProgramAdmin)
  282 +admin.site.register(Sequence, SequenceAdmin)
  283 +admin.site.register(SiteWatch)
  284 +admin.site.register(SiteWatchHistory)
  285 +#admin.site.register(StrategyObs, StrategyObsAdmin)
  286 +##admin.site.register(TaskId)
  287 +admin.site.register(Telescope, TelescopeAdmin)
  288 +admin.site.register(PyrosUser, PyrosUserAdmin)
  289 +admin.site.register(UserLevel, UserLevelAdmin)
  290 +admin.site.register(Version)
  291 +admin.site.register(WeatherWatch)
  292 +admin.site.register(WeatherWatchHistory)
  293 +admin.site.register(PlcDeviceStatus)
  294 +admin.site.register(PlcDevice)
  295 +admin.site.register(Config)
  296 +admin.site.register(Institute)
4 297 \ No newline at end of file
... ...
src/core/pyros_django/dashboard/models.py
  1 +##from __future__ import unicode_literals
  2 +
  3 +# (EP 21/9/22) To allow autoreferencing (ex: AgentCmd.create() returns a AgentCmd)
  4 +from __future__ import annotations
  5 +
  6 +# Stdlib imports
  7 +from src.device_controller.abstract_component.device_controller import DeviceCmd
  8 +from datetime import datetime, timedelta, date
  9 +from dateutil.relativedelta import relativedelta
  10 +import os
  11 +import sys
  12 +from typing import Any, List, Tuple, Optional
  13 +import re
  14 +
  15 +# Django imports
  16 +from django.core.validators import MaxValueValidator, MinValueValidator
  17 +
  18 +# DJANGO imports
  19 +from django.contrib.auth.models import AbstractUser, UserManager
1 20 from django.db import models
  21 +from django.db.models import Q, Max
  22 +from django.core.validators import MaxValueValidator, MinValueValidator
  23 +#from django.db.models.deletion import DO_NOTHING
  24 +from django.db.models.expressions import F
  25 +from django.db.models.query import QuerySet
  26 +from model_utils import Choices
  27 +from django.utils import timezone
  28 +from asgiref.sync import async_to_sync
  29 +from channels.layers import get_channel_layer
  30 +from django.db.models.signals import post_save
  31 +from django.dispatch import receiver
  32 +# Project imports
  33 +from user_mgmt.models import PyrosUser
  34 +# DeviceCommand is used by class Command
  35 +sys.path.append("../../..")
  36 +
  37 +
  38 +
  39 +
  40 +"""
  41 +STYLE RULES
  42 +===========
  43 +https://simpleisbetterthancomplex.com/tips/2018/02/10/django-tip-22-designing-better-models.html
  44 +https://steelkiwi.com/blog/best-practices-working-django-models-python/
  45 +
  46 +- Model name => singular
  47 + Call it Company instead of Companies.
  48 + A model definition is the representation of a single object (the object in this example is a company),
  49 + and not a collection of companies
  50 + The model definition is a class, so always use CapWords convention (no underscores)
  51 + E.g. User, Permission, ContentType, etc.
  52 +
  53 +- For the model’s attributes use snake_case.
  54 + E.g. first_name, last_name, etc
  55 +
  56 +- Blank and Null Fields (https://simpleisbetterthancomplex.com/tips/2016/07/25/django-tip-8-blank-or-null.html)
  57 + - Null: It is database-related. Defines if a given database column will accept null values or not.
  58 + - Blank: It is validation-related. It will be used during forms validation, when calling form.is_valid().
  59 + Do not use null=True for text-based fields that are optional.
  60 + Otherwise, you will end up having two possible values for “no data”, that is: None and an empty string.
  61 + Having two possible values for “no data” is redundant.
  62 + The Django convention is to use the empty string, not NULL.
  63 + Example:
  64 + # The default values of `null` and `blank` are `False`.
  65 + class Person(models.Model):
  66 + name = models.CharField(max_length=255) # Mandatory
  67 + bio = models.TextField(max_length=500, blank=True) # Optional (don't put null=True)
  68 + birth_date = models.DateField(null=True, blank=True) # Optional (here you may add null=True)
  69 + The default values of null and blank are False.
  70 + Special case, when you need to accept NULL values for a BooleanField, use NullBooleanField instead.
  71 +
  72 +- Choices : you can use Choices from the model_utils library. Take model Article, for instance:
  73 + from model_utils import Choices
  74 + class Article(models.Model):
  75 + STATUSES = Choices(
  76 + (0, 'draft', _('draft')),
  77 + (1, 'published', _('published')) )
  78 + status = models.IntegerField(choices=STATUSES, default=STATUSES.draft)
  79 +
  80 +- Reverse Relationships
  81 +
  82 + - related_name :
  83 + Rule of thumb: if you are not sure what would be the related_name,
  84 + use the plural of the model holding the ForeignKey.
  85 + ex:
  86 + class Company:
  87 + name = models.CharField(max_length=30)
  88 + class Employee:
  89 + first_name = models.CharField(max_length=30)
  90 + last_name = models.CharField(max_length=30)
  91 + company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='employees')
  92 + usage:
  93 + google = Company.objects.get(name='Google')
  94 + google.employees.all()
  95 + You can also use the reverse relationship to modify the company field on the Employee instances:
  96 + vitor = Employee.objects.get(first_name='Vitor')
  97 + google = Company.objects.get(name='Google')
  98 + google.employees.add(vitor)
  99 +
  100 + - related_query_name :
  101 + This kind of relationship also applies to query filters.
  102 + For example, if I wanted to list all companies that employs people named ‘Vitor’, I could do the following:
  103 + companies = Company.objects.filter(employee__first_name='Vitor')
  104 + If you want to customize the name of this relationship, here is how we do it:
  105 + class Employee:
  106 + first_name = models.CharField(max_length=30)
  107 + last_name = models.CharField(max_length=30)
  108 + company = models.ForeignKey(
  109 + Company,
  110 + on_delete=models.CASCADE,
  111 + related_name='employees',
  112 + related_query_name='person'
  113 + )
  114 + Then the usage would be:
  115 + companies = Company.objects.filter(person__first_name='Vitor')
  116 +
  117 + To use it consistently, related_name goes as plural and related_query_name goes as singular.
  118 +
  119 +
  120 +GENERAL EXAMPLE
  121 +=======
  122 +
  123 +from django.db import models
  124 +from django.urls import reverse
  125 +
  126 +class Company(models.Model):
  127 + # CHOICES
  128 + PUBLIC_LIMITED_COMPANY = 'PLC'
  129 + PRIVATE_COMPANY_LIMITED = 'LTD'
  130 + LIMITED_LIABILITY_PARTNERSHIP = 'LLP'
  131 + COMPANY_TYPE_CHOICES = (
  132 + (PUBLIC_LIMITED_COMPANY, 'Public limited company'),
  133 + (PRIVATE_COMPANY_LIMITED, 'Private company limited by shares'),
  134 + (LIMITED_LIABILITY_PARTNERSHIP, 'Limited liability partnership'),
  135 + )
  136 +
  137 + # DATABASE FIELDS
  138 + name = models.CharField('name', max_length=30)
  139 + vat_identification_number = models.CharField('VAT', max_length=20)
  140 + company_type = models.CharField('type', max_length=3, choices=COMPANY_TYPE_CHOICES)
  141 +
  142 + # MANAGERS
  143 + objects = models.Manager()
  144 + limited_companies = LimitedCompanyManager()
  145 +
  146 + # META CLASS
  147 + class Meta:
  148 + verbose_name = 'company'
  149 + verbose_name_plural = 'companies'
  150 +
  151 + # TO STRING METHOD
  152 + def __str__(self):
  153 + return self.name
  154 +
  155 + # SAVE METHOD
  156 + def save(self, *args, **kwargs):
  157 + do_something()
  158 + super().save(*args, **kwargs) # Call the "real" save() method.
  159 + do_something_else()
  160 +
  161 + # ABSOLUTE URL METHOD
  162 + def get_absolute_url(self):
  163 + return reverse('company_details', kwargs={'pk': self.id})
  164 +
  165 + # OTHER METHODS
  166 + def process_invoices(self):
  167 + do_something()
  168 +
  169 +"""
  170 +
  171 +
  172 +# ---
  173 +# --- Utility functions
  174 +# ---
  175 +
  176 +def printd(*args, **kwargs):
  177 + if os.environ.get('PYROS_DEBUG', '0') == '1':
  178 + print('(MODEL)', *args, **kwargs)
  179 +
  180 +'''
  181 +def get_or_create_unique_row_from_model(model: models.Model):
  182 + # return model.objects.get(id=1) if model.objects.exists() else model.objects.create(id=1)
  183 + return model.objects.first() if model.objects.exists() else model.objects.create(id=1)
  184 +'''
  185 +
  186 +
  187 +
  188 +
  189 +
  190 +"""
  191 +------------------------
  192 + BASE MODEL CLASSES
  193 +------------------------
  194 +"""
  195 +
  196 +"""
  197 +------------------------
  198 + OTHER MODEL CLASSES
  199 +------------------------
  200 +"""
  201 +
  202 +
  203 +# TODO: OLD Config : à virer (mais utilisé dans dashboard/templatetags/tags.py)
  204 +class Config(models.Model):
  205 + PYROS_STATE = ["Starting", "Passive", "Standby",
  206 + "Remote", "Startup", "Scheduler", "Closing"]
  207 +
  208 + id = models.IntegerField(default='1', primary_key=True)
  209 + #latitude = models.FloatField(default=1)
  210 + latitude = models.DecimalField(
  211 + max_digits=4, decimal_places=2,
  212 + default=1,
  213 + validators=[
  214 + MaxValueValidator(90),
  215 + MinValueValidator(-90)
  216 + ]
  217 + )
  218 + local_time_zone = models.FloatField(default=1)
  219 + #longitude = models.FloatField(default=1)
  220 + longitude = models.DecimalField(
  221 + max_digits=5, decimal_places=2,
  222 + default=1,
  223 + validators=[
  224 + MaxValueValidator(360),
  225 + MinValueValidator(-360)
  226 + ]
  227 + )
  228 + altitude = models.FloatField(default=1)
  229 + horizon_line = models.FloatField(default=1)
  230 + row_data_save_frequency = models.IntegerField(default='300')
  231 + request_frequency = models.IntegerField(default='300')
  232 + analysed_data_save = models.IntegerField(default='300')
  233 + telescope_ip_address = models.CharField(max_length=45, default="127.0.0.1")
  234 + camera_ip_address = models.CharField(max_length=45, default="127.0.0.1")
  235 + plc_ip_address = models.CharField(max_length=45, default="127.0.0.1")
  236 +
  237 + # TODO: changer ça, c'est pas clair du tout...
  238 + # True = mode Scheduler-standby, False = mode Remote !!!!
  239 + global_mode = models.BooleanField(default='True')
  240 +
  241 + ack = models.BooleanField(default='False')
  242 + bypass = models.BooleanField(default='True')
  243 + lock = models.BooleanField(default='False')
  244 + pyros_state = models.CharField(max_length=25, default=PYROS_STATE[0])
  245 + force_passive_mode = models.BooleanField(default='False')
  246 + plc_timeout_seconds = models.PositiveIntegerField(default=60)
  247 + majordome_state = models.CharField(max_length=25, default="")
  248 + ntc = models.BooleanField(default='False')
  249 + majordome_restarted = models.BooleanField(default='False')
  250 +
  251 + class Meta:
  252 + managed = True
  253 + db_table = 'config'
  254 + verbose_name_plural = "Config"
  255 +
  256 + def __str__(self):
  257 + return (str(self.__dict__))
  258 +
  259 +
  260 +
  261 +class Log(models.Model):
  262 + agent = models.CharField(max_length=45, blank=True, null=True)
  263 + created = models.DateTimeField(blank=True, null=True, auto_now_add=True)
  264 + message = models.TextField(blank=True, null=True)
  265 +
  266 + class Meta:
  267 + managed = True
  268 + db_table = 'log'
  269 +
  270 + def __str__(self):
  271 + return (str(self.agent))
  272 +
  273 +
  274 +# TODO: à virer car utilisé pour Celery (ou bien à utiliser pour les agents)
  275 +'''
  276 +class TaskId(models.Model):
  277 + task = models.CharField(max_length=45, blank=True, null=True)
  278 + created = models.DateTimeField(blank=True, null=True, auto_now_add=True)
  279 + task_id = models.CharField(max_length=45, blank=True, null=True)
  280 +
  281 + class Meta:
  282 + managed = True
  283 + db_table = 'task_id'
  284 +
  285 + def __str__(self):
  286 + return (str(self.task) + " - " + str(self.task_id))
  287 +'''
  288 +
  289 +
  290 +class Version(models.Model):
  291 + module_name = models.CharField(max_length=45, blank=True, null=True)
  292 + version = models.CharField(max_length=15, blank=True, null=True)
  293 + created = models.DateTimeField(blank=True, null=True, auto_now_add=True)
  294 + updated = models.DateTimeField(blank=True, null=True, auto_now=True)
  295 +
  296 + class Meta:
  297 + managed = True
  298 + db_table = 'version'
  299 +
  300 + def __str__(self):
  301 + return (str(self.module_name) + " - " + str(self.version))
  302 +
  303 +
  304 +class Tickets(models.Model):
  305 + created = models.DateTimeField(blank=True, null=True, auto_now_add=True)
  306 + updated = models.DateTimeField(blank=True, null=True, auto_now=True)
  307 + end = models.DateTimeField(blank=True, null=True)
  308 + title = models.TextField(blank=True, null=True)
  309 + description = models.TextField(blank=True, null=True)
  310 + resolution = models.TextField(blank=True, null=True)
  311 + pyros_user = models.ForeignKey(PyrosUser, on_delete=models.DO_NOTHING, related_name="tickets", blank=True, null=True)
  312 + last_modified_by = models.ForeignKey(PyrosUser, on_delete=models.DO_NOTHING, related_name="tickets_modified_by", blank=True, null=True)
  313 + LEVEL_ONE = "ONE"
  314 + LEVEL_TWO = "TWO"
  315 + LEVEL_THREE = "THREE"
  316 + LEVEL_FOUR = "FOUR"
  317 + LEVEL_FIVE = "FIVE"
  318 + SECURITY_LEVEL_CHOICES = (
  319 + (LEVEL_ONE,"Warning non compromising for the operation of the system"),
  320 + (LEVEL_TWO,"Known issue which can be solved by operating the software remotely"),
  321 + (LEVEL_THREE,"Known issue which can be solved by an human remotely"),
  322 + (LEVEL_FOUR,"Known issue without immediate solution"),
  323 + (LEVEL_FIVE,"Issue not categorized until it happened")
  324 + )
  325 + security_level = models.TextField(choices=SECURITY_LEVEL_CHOICES, default=LEVEL_ONE)
  326 +
  327 + class Meta:
  328 + managed = True
  329 + db_table = 'tickets'
  330 + verbose_name_plural = "tickets"
  331 +
2 332  
3   -# Create your models here.
... ...