Merge pull request #48 from TurTaskProject/modify/tasks-remodeling

Remodel task to match new functionality (Kanban)
This commit is contained in:
Sirin Puenggun 2023-11-20 17:08:14 +07:00 committed by GitHub
commit 973df30909
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 469 additions and 116 deletions

View File

11
backend/boards/admin.py Normal file
View File

@ -0,0 +1,11 @@
from django.contrib import admin
from .models import Board, ListBoard
@admin.register(Board)
class BoardAdmin(admin.ModelAdmin):
list_display = ['name', 'user']
@admin.register(ListBoard)
class ListBoardAdmin(admin.ModelAdmin):
list_display = ['name', 'position', 'board']
list_filter = ['board', 'position']

9
backend/boards/apps.py Normal file
View File

@ -0,0 +1,9 @@
from django.apps import AppConfig
class BoardsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'boards'
def ready(self):
import boards.signals

View File

@ -0,0 +1,35 @@
# Generated by Django 4.2.6 on 2023-11-19 19:19
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Board',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ListBoard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('position', models.IntegerField()),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boards.board')),
],
),
]

View File

34
backend/boards/models.py Normal file
View File

@ -0,0 +1,34 @@
from django.db import models
from users.models import CustomUser
class Board(models.Model):
"""
Kanban board model.
:param user: The user who owns the board.
:param name: The name of the board.
:param created_at: The date and time when the board was created.
"""
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self) -> str:
return f"{self.name}"
class ListBoard(models.Model):
"""
List inside a Kanban board.
:param board: The board that the list belongs to.
:param name: The name of the list.
:param position: The position of the list in Kanban.
"""
board = models.ForeignKey(Board, on_delete=models.CASCADE)
name = models.CharField(max_length=255)
position = models.IntegerField()
def __str__(self) -> str:
return f"{self.name}"

14
backend/boards/signals.py Normal file
View File

@ -0,0 +1,14 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from boards.models import Board, ListBoard
from users.models import CustomUser
@receiver(post_save, sender=CustomUser)
def create_default_board(sender, instance, created, **kwargs):
if created:
board = Board.objects.create(user=instance, name="My Default Board")
ListBoard.objects.create(board=board, name="Todo", position=1)
ListBoard.objects.create(board=board, name="In Progress", position=2)
ListBoard.objects.create(board=board, name="Done", position=3)

3
backend/boards/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

5
backend/boards/urls.py Normal file
View File

@ -0,0 +1,5 @@
from django.urls import path
urlpatterns = [
]

3
backend/boards/views.py Normal file
View File

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@ -53,6 +53,7 @@ INSTALLED_APPS = [
'users',
'authentications',
'dashboard',
'boards',
'corsheaders',
'drf_spectacular',

View File

@ -28,4 +28,5 @@ urlpatterns = [
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
path('api/', include('dashboard.urls')),
path('api/', include('boards.urls')),
]

View File

@ -1,3 +1,29 @@
from django.contrib import admin
from .models import Tag, Todo, RecurrenceTask, RecurrencePattern, Habit, Subtask
# Register your models here.
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ['name']
@admin.register(Todo)
class TodoAdmin(admin.ModelAdmin):
list_display = ['title', 'list_board', 'is_active', 'priority']
list_filter = ['list_board', 'is_active', 'priority']
@admin.register(RecurrenceTask)
class RecurrenceTaskAdmin(admin.ModelAdmin):
list_display = ['title', 'list_board', 'rrule', 'is_active']
list_filter = ['list_board', 'rrule', 'is_active']
@admin.register(RecurrencePattern)
class RecurrencePatternAdmin(admin.ModelAdmin):
list_display = ['recurrence_task', 'recurring_type', 'day_of_week', 'week_of_month', 'day_of_month', 'month_of_year']
@admin.register(Habit)
class HabitAdmin(admin.ModelAdmin):
list_display = ['title', 'streak', 'current_count']
@admin.register(Subtask)
class SubtaskAdmin(admin.ModelAdmin):
list_display = ['parent_task', 'description', 'completed']
list_filter = ['parent_task', 'completed']

View File

@ -6,9 +6,9 @@ from rest_framework import viewsets
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from tasks.utils import get_service, generate_recurrence_rule
from tasks.models import Todo, RecurrenceTask
from tasks.serializers import TodoUpdateSerializer, RecurrenceTaskUpdateSerializer
from tasks.utils import get_service
from tasks.models import Todo
from tasks.serializers import TodoUpdateSerializer
class GoogleCalendarEventViewset(viewsets.ViewSet):
"""Viewset for list or save Google Calendar Events."""
@ -50,7 +50,11 @@ class GoogleCalendarEventViewset(viewsets.ViewSet):
return events
def _validate_serializer(self, serializer):
"""Validate serializer and return response."""
"""
Validate serializer and return response.
:param serializer: The serializer to validate.
"""
if serializer.is_valid():
serializer.save()
return Response("Validate Successfully", status=200)
@ -61,7 +65,6 @@ class GoogleCalendarEventViewset(viewsets.ViewSet):
events = self._get_google_events(request)
responses = []
recurrence_task_ids = []
for event in events:
start_datetime = event.get('start', {}).get('dateTime')
end_datetime = event.get('end', {}).get('dateTime')
@ -71,25 +74,6 @@ class GoogleCalendarEventViewset(viewsets.ViewSet):
event.pop('start')
event.pop('end')
if (event.get('recurringEventId') in recurrence_task_ids):
continue
if (event.get('recurringEventId') is not None):
originalStartTime = event.get('originalStartTime', {}).get('dateTime')
rrule_text = generate_recurrence_rule(event['start_datetime'], event['end_datetime'], originalStartTime)
event['recurrence'] = rrule_text
event.pop('originalStartTime')
recurrence_task_ids.append(event['recurringEventId'])
try:
task = RecurrenceTask.objects.get(google_calendar_id=event['id'])
serializer = RecurrenceTaskUpdateSerializer(instance=task, data=event)
except RecurrenceTask.DoesNotExist:
serializer = RecurrenceTaskUpdateSerializer(data=event, user=request.user)
responses.append(self._validate_serializer(serializer))
continue
try:
task = Todo.objects.get(google_calendar_id=event['id'])
serializer = TodoUpdateSerializer(instance=task, data=event)

View File

@ -0,0 +1,107 @@
# Generated by Django 4.2.6 on 2023-11-19 20:15
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('boards', '0001_initial'),
('tasks', '0014_recurrencetask_completed_todo_completed'),
]
operations = [
migrations.CreateModel(
name='RecurrencePattern',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recurring_type', models.IntegerField(choices=[(0, 'Daily'), (1, 'Weekly'), (2, 'Monthly'), (3, 'Yearly')])),
('max_occurrences', models.IntegerField(default=0)),
('day_of_week', models.IntegerField(choices=[(0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'), (4, 'Friday'), (5, 'Saturday'), (6, 'Sunday')])),
('week_of_month', models.IntegerField(choices=[(1, 'First'), (2, 'Second'), (3, 'Third'), (4, 'Fourth'), (5, 'Last')])),
('day_of_month', models.IntegerField(default=0)),
('month_of_year', models.IntegerField(choices=[(1, 'January'), (2, 'February'), (3, 'March'), (4, 'April'), (5, 'May'), (6, 'June'), (7, 'July'), (8, 'August'), (9, 'September'), (10, 'October'), (11, 'November'), (12, 'December')])),
],
),
migrations.RemoveField(
model_name='transaction',
name='user',
),
migrations.DeleteModel(
name='UserNotification',
),
migrations.RemoveField(
model_name='habit',
name='end_event',
),
migrations.RemoveField(
model_name='habit',
name='google_calendar_id',
),
migrations.RemoveField(
model_name='habit',
name='start_event',
),
migrations.RemoveField(
model_name='recurrencetask',
name='google_calendar_id',
),
migrations.RemoveField(
model_name='recurrencetask',
name='recurrence_rule',
),
migrations.AddField(
model_name='habit',
name='current_count',
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name='recurrencetask',
name='is_active',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='recurrencetask',
name='is_full_day_event',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='recurrencetask',
name='list_board',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'),
),
migrations.AddField(
model_name='recurrencetask',
name='parent_task',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='tasks.recurrencetask'),
),
migrations.AddField(
model_name='recurrencetask',
name='rrule',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='todo',
name='is_active',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='todo',
name='is_full_day_event',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='todo',
name='list_board',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'),
),
migrations.DeleteModel(
name='Transaction',
),
migrations.AddField(
model_name='recurrencepattern',
name='recurrence_task',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tasks.recurrencetask'),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.6 on 2023-11-19 20:24
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('boards', '0001_initial'),
('tasks', '0015_recurrencepattern_remove_transaction_user_and_more'),
]
operations = [
migrations.AlterField(
model_name='recurrencetask',
name='list_board',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'),
),
migrations.AlterField(
model_name='todo',
name='list_board',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.6 on 2023-11-19 20:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('boards', '0001_initial'),
('tasks', '0016_alter_recurrencetask_list_board_and_more'),
]
operations = [
migrations.AlterField(
model_name='recurrencetask',
name='list_board',
field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'),
),
migrations.AlterField(
model_name='todo',
name='list_board',
field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'),
),
]

View File

@ -1,6 +1,8 @@
from django.db import models
from django.conf import settings
from boards.models import ListBoard, Board
class Tag(models.Model):
"""
Represents a tag that can be associated with tasks.
@ -12,7 +14,7 @@ class Tag(models.Model):
class Task(models.Model):
"""
Represents a Abstract of task, such as Habit, Daily, Todo, or Reward.
Represents a Abstract of task, such as Habit, Recurrence, Todo.
:param user: The user who owns the task.
:param title: Title of the task.
@ -23,10 +25,6 @@ class Task(models.Model):
:param challenge: Associated challenge (optional).
:param fromSystem: A boolean field indicating if the task is from System.
:param creation_date: Creation date of the task.
:param last_update: Last updated date of the task.
:param: google_calendar_id: Google Calendar Event ID of the task.
:param start_event: Start event of the task.
:param end_event: End event(Due Date) of the task.
"""
class Difficulty(models.IntegerChoices):
EASY = 1, 'Easy'
@ -45,22 +43,36 @@ class Task(models.Model):
fromSystem = models.BooleanField(default=False)
creation_date = models.DateTimeField(auto_now_add=True)
last_update = models.DateTimeField(auto_now=True)
google_calendar_id = models.CharField(max_length=255, null=True, blank=True)
start_event = models.DateTimeField(null=True)
end_event = models.DateTimeField(null=True)
class Meta:
abstract = True
class Todo(Task):
"""
Represent a Todo task.
:param list_board: The list board that the task belongs to.
:param is_active: A boolean field indicating whether the task is active. (Archive or not)
:param is_full_day_event: A boolean field indicating whether the task is a full day event.
:param start_event: Start date and time of the task.
:param end_event: End date and time of the task.
:param google_calendar_id: The Google Calendar ID of the task.
:param completed: A boolean field indicating whether the task is completed.
:param priority: The priority of the task (range: 1 to 4).
"""
class EisenhowerMatrix(models.IntegerChoices):
IMPORTANT_URGENT = 1, 'Important & Urgent'
IMPORTANT_NOT_URGENT = 2, 'Important & Not Urgent'
NOT_IMPORTANT_URGENT = 3, 'Not Important & Urgent'
NOT_IMPORTANT_NOT_URGENT = 4, 'Not Important & Not Urgent'
list_board = models.ForeignKey(ListBoard, on_delete=models.CASCADE, null=True, default=1)
is_active = models.BooleanField(default=True)
is_full_day_event = models.BooleanField(default=False)
start_event = models.DateTimeField(null=True)
end_event = models.DateTimeField(null=True)
google_calendar_id = models.CharField(max_length=255, null=True, blank=True)
completed = models.BooleanField(default=False)
priority = models.PositiveSmallIntegerField(choices=EisenhowerMatrix.choices, default=EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT)
@ -68,15 +80,95 @@ class Todo(Task):
return self.title
class RecurrenceTask(Task):
"""
Represent a Recurrence task. (Occure every day, week, month, year)
:param list_board: The list board that the task belongs to.
:param rrule: The recurrence rule of the task.
:param is_active: A boolean field indicating whether the task is active. (Archive or not)
:param is_full_day_event: A boolean field indicating whether the task is a full day event.
:param start_event: Start date and time of the task.
:param end_event: End date and time of the task.
:param completed: A boolean field indicating whether the task is completed.
:param parent_task: The parent task of the subtask.
"""
list_board = models.ForeignKey(ListBoard, on_delete=models.CASCADE, null=True, default=1)
rrule = models.CharField(max_length=255, null=True, blank=True)
is_active = models.BooleanField(default=True)
is_full_day_event = models.BooleanField(default=False)
start_event = models.DateTimeField(null=True)
end_event = models.DateTimeField(null=True)
completed = models.BooleanField(default=False)
recurrence_rule = models.CharField()
parent_task = models.ForeignKey("self", on_delete=models.CASCADE, null=True)
def __str__(self) -> str:
return f"{self.title} ({self.recurrence_rule})"
class RecurrencePattern(models.Model):
"""
:param recurrence_task: The recurrence task that the pattern belongs to.
:param recurring_type: The type of recurrence.
:param max_occurrences: The maximum number of occurrences.
:param day_of_week: The day of the week that event will occure.
:param week_of_month: The week of the month that event will occure.
:param day_of_month: The day of the month that event will occure.
:param month_of_year: The month of the year that event will occure.
"""
class RecurringType(models.IntegerChoices):
DAILY = 0, 'Daily'
WEEKLY = 1, 'Weekly'
MONTHLY = 2, 'Monthly'
YEARLY = 3, 'Yearly'
class DayOfWeek(models.IntegerChoices):
MONDAY = 0, 'Monday'
TUESDAY = 1, 'Tuesday'
WEDNESDAY = 2, 'Wednesday'
THURSDAY = 3, 'Thursday'
FRIDAY = 4, 'Friday'
SATURDAY = 5, 'Saturday'
SUNDAY = 6, 'Sunday'
class WeekOfMonth(models.IntegerChoices):
FIRST = 1, 'First'
SECOND = 2, 'Second'
THIRD = 3, 'Third'
FOURTH = 4, 'Fourth'
LAST = 5, 'Last'
class MonthOfYear(models.IntegerChoices):
JANUARY = 1, 'January'
FEBRUARY = 2, 'February'
MARCH = 3, 'March'
APRIL = 4, 'April'
MAY = 5, 'May'
JUNE = 6, 'June'
JULY = 7, 'July'
AUGUST = 8, 'August'
SEPTEMBER = 9, 'September'
OCTOBER = 10, 'October'
NOVEMBER = 11, 'November'
DECEMBER = 12, 'December'
recurrence_task = models.ForeignKey(RecurrenceTask, on_delete=models.CASCADE)
recurring_type = models.IntegerField(choices=RecurringType.choices)
max_occurrences = models.IntegerField(default=0)
day_of_week = models.IntegerField(choices=DayOfWeek.choices)
week_of_month = models.IntegerField(choices=WeekOfMonth.choices)
day_of_month = models.IntegerField(default=0)
month_of_year = models.IntegerField(choices=MonthOfYear.choices)
class Habit(Task):
"""
Represent a Habit task with streaks.
:param streak: The streak of the habit.
:param current_count: The current count of the habit.
"""
streak = models.IntegerField(default=0)
current_count = models.IntegerField(default=0)
def __str__(self) -> str:
return f"{self.title} ({self.streak})"
@ -91,67 +183,4 @@ class Subtask(models.Model):
"""
parent_task = models.ForeignKey(Todo, on_delete=models.CASCADE)
description = models.TextField()
completed = models.BooleanField(default=False)
class UserNotification(models.Model):
"""
Represents a user notification.
:param type: The type of the notification (e.g., 'NEW_CHAT_MESSAGE').
:param data: JSON data associated with the notification.
:param seen: A boolean field indicating whether the notification has been seen.
"""
NOTIFICATION_TYPES = (
('LEVEL_UP', 'Level Up'),
('DEATH', 'Death'),
)
type = models.CharField(max_length=255, choices=[type for type in NOTIFICATION_TYPES])
data = models.JSONField(default=dict)
seen = models.BooleanField(default=False)
@staticmethod
def clean_notification(notifications):
"""
Cleanup function for removing corrupt notification data:
- Removes notifications with null or missing id or type.
"""
if not notifications:
return notifications
filtered_notifications = []
for notification in notifications:
if notification.id is None or notification.type is None:
continue
return filtered_notifications
class Transaction(models.Model):
"""
Represents a transaction involving currencies in the system.
:param currency: The type of currency used in the transaction
:param transactionType: The type of the transaction
:param description: Additional text.
:param amount: The transaction amount.
:param user: The user involved in the transaction.
"""
CURRENCIES = (('gold', 'Gold'),)
TRANSACTION_TYPES = (
('buy_gold', 'Buy Gold'),
('spend', 'Spend'),
('debug', 'Debug'),
('force_update_gold', 'Force Update Gold'),
)
currency = models.CharField(max_length=12, choices=CURRENCIES)
transaction_type = models.CharField(max_length=24, choices=TRANSACTION_TYPES)
description = models.TextField(blank=True)
amount = models.FloatField(default=0)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
def __str__(self):
return f"Transaction ({self.id})"
completed = models.BooleanField(default=False)

View File

@ -1,5 +1,4 @@
from rest_framework import serializers
from django.utils.dateparse import parse_datetime
from .models import Todo, RecurrenceTask

View File

@ -1,7 +1,8 @@
from django.db.models.signals import pre_save
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.utils import timezone
from boards.models import ListBoard
from tasks.models import Todo
@ -22,4 +23,17 @@ def update_priority(sender, instance, **kwargs):
elif time_until_due <= urgency_threshold and instance.importance < importance_threshold:
instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_URGENT
else:
instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT
instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT
@receiver(post_save, sender=Todo)
def assign_todo_to_listboard(sender, instance, created, **kwargs):
if created:
user_board = instance.user.board_set.first()
if user_board:
first_list_board = user_board.listboard_set.order_by('position').first()
if first_list_board:
instance.list_board = first_list_board
instance.save()

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.6 on 2023-11-19 20:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0005_alter_userstats_endurance_and_more'),
]
operations = [
migrations.RemoveField(
model_name='userstats',
name='endurance',
),
migrations.RemoveField(
model_name='userstats',
name='intelligence',
),
migrations.RemoveField(
model_name='userstats',
name='luck',
),
migrations.RemoveField(
model_name='userstats',
name='perception',
),
migrations.RemoveField(
model_name='userstats',
name='strength',
),
migrations.AddField(
model_name='customuser',
name='last_name',
field=models.CharField(blank=True, max_length=150),
),
]

View File

@ -5,16 +5,18 @@ from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.core.validators import MinValueValidator, MaxValueValidator
from .managers import CustomAccountManager
class CustomUser(AbstractBaseUser, PermissionsMixin):
# User fields
"""
User model where email is the unique identifier for authentication.
"""
email = models.EmailField(_('email address'), unique=True)
username = models.CharField(max_length=150, unique=True)
first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True)
start_date = models.DateTimeField(default=timezone.now)
about = models.TextField(_('about'), max_length=500, blank=True)
profile_pic = models.ImageField(upload_to='profile_pics', null=True, blank=True, default='profile_pics/default.png')
@ -35,7 +37,6 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
# String representation of the user
return self.username
def random_luck():
return random.randint(1, 50)
@ -51,17 +52,6 @@ class UserStats(models.Model):
health = models.IntegerField(default=100)
gold = models.FloatField(default=0.0)
experience = models.FloatField(default=0)
strength = models.IntegerField(default=1,
validators=[MinValueValidator(1),
MaxValueValidator(100)])
intelligence = models.IntegerField(default=1, validators=[MinValueValidator(1),
MaxValueValidator(100)])
endurance = models.IntegerField(default=1, validators=[MinValueValidator(1),
MaxValueValidator(100)])
perception = models.IntegerField(default=1, validators=[MinValueValidator(1),
MaxValueValidator(100)])
luck = models.IntegerField(default=random_luck, validators=[MinValueValidator(1),
MaxValueValidator(50)],)
@property
def level(self):