Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • inject/backend
1 result
Show changes
Commits on Source (16)
Showing
with 368 additions and 105 deletions
## Running the project
To run the backend of INJECT, you have two options, each with its own set of requirements. The first method utilizes [poetry](#running-the-application-with-poetry), while the second employs [Docker](#running-the-application-with-docker).
Before running the project, ensure that the necessary local variables are properly configured. Here's a breakdown of these variables:
- `INJECT_DEBUG`: _boolean, default=false_ - Run the backend in debug mode. **Do not set in production.**
- `INJECT_HOST_ADDRESSES`: _string, default=localhost_ - A comma-separated list of allowed hosts.
- `CORS_ALLOWED_ORIGINS`: _string, default=http://localhost_ - A comma-separated list of origins (domains) that are authorized to make cross-site HTTP requests.
- `INJECT_NOAUTH`: _boolean, default=false_ - If set, authorization is turned off.
- `INJECT_EMAIL_HOST`: _string, default=""_ - SMTP server address.
- `INJECT_EMAIL_PORT`: _int, default=25_ - Port to use for the SMTP server defined in INJECT_EMAIL_HOST.
- `INJECT_EMAIL_HOST_USER`: _string, default=""_ - Username to use for authentication to the SMTP server defined in _INJECT_EMAIL_HOST_.
- `INJECT_EMAIL_HOST_PASSWORD`: _string, default=""_ - Password to use for the SMTP server defined in _INJECT_EMAIL_HOST_. This setting is used in conjunction with _INJECT_EMAIL_HOST_USER_ when authenticating to the SMTP server.
- `INJECT_EMAIL_SENDER_ADDRESS`: _string, default=""_ - The sender address for automatic emails.
- `INJECT_LOGS`: _string, default=backend-logs.log_ - Path to a file where to save logs.
### Running the Application with poetry:
To run the backend application using poetry, ensure you have the following prerequisites:
* [python](https://www.python.org/) with a supported version of 3.8.10
* [poetry](https://python-poetry.org/)
Once you have these installed, follow these steps:
Use Poetry to install the necessary dependencies:
```
poetry install
```
Apply any pending database migrations:
```
poetry run python manage.py migrate
```
And then lastly launch the backend server:
```
poetry run python manage.py runserver
```
### Running the Application with Docker:
There is a `Dockerfile` present in this repository, which can be used to build an image of the current version of the backend.
Image can be built with this command:
```bash
docker build -t backend .
```
Once the image is created, you can start the container with this command:
```bash
docker run --name backend -p8000:8000 -d backend gunicorn
```
You can access the backend the way described in the same way as when running locally.
Image allows running of unit tests as well, this can be done via:
```bash
docker run --rm backend test
```
## Deployment
The backend is made to be deployed using [gunicorn](https://gunicorn.org/) with [uvicorn](https://www.uvicorn.org/) workers.
Specific versions of these servers are included in the poetry environment.
To start the server, run this one-line command:
```
poetry python -m gunicorn ttxbackend.asgi:application -k uvicorn.workers.UvicornWorker
```
This will start a gunicorn server using uvicorn workers. `uvicorn` is required because `gunicorn`
does not support websockets, which are necessary for GraphQL subscriptions.
Currently, it is not recommended to use **more than 1 worker**.
An additional environment variable `HOST_ADDRESSES` must be set with a comma-separated list of
allowed host addresses.
In Docker this can be done followingly:
```bash
docker run --name backend -e HOST_ADDRESSES="172.26.0.1,192.168.0.1" -p8000:8000 -d backend gunicorn
\ No newline at end of file
......@@ -4,8 +4,7 @@ This repository contains the INJECT project.
## Quick list
- [Documentation](#documentation)
- [Running the Application](#running-the-application)
- [Running the Application with Docker](#running-the-application-with-docker)
- [Instalation and runnning the application](./INSTALATION.md)
- [Formatting with Black](#formatting-with-black)
- [Django Administration Panel](#django-administration-panel)
- [Django-nose unit tests](#django-nose-unit-tests)
......@@ -21,52 +20,6 @@ More details can be found [here](./dev/README.md#backend-versioning).
GraphQL documentation can be interacted with through GraphiQL interface available at http://127.0.0.1:8000/ttxbackend/api/v1/graphql/.
### Running the Application:
```
curl -sSL https://install.python-poetry.org | python3 -
poetry install
poetry run python manage.py migrate
poetry run python manage.py runserver
```
### Running the Application with Docker:
There is a `Dockerfile` present in this repository, which can be used to build an image of the current version of the backend.
Image can be built with this command:
```bash
docker build -t backend .
```
Once the image is created, you can start the container with this command:
```bash
docker run --name backend -p8000:8000 -d backend gunicorn
```
You can access the backend the way described in the same way as when running locally.
Image allows running of unit tests as well, this can be done via:
```bash
docker run --rm backend test
```
### Deployment
The backend is made to be deployed using [gunicorn](https://gunicorn.org/) with [uvicorn](https://www.uvicorn.org/) workers.
Specific versions of these servers are included in the poetry environment.
To start the server, run this command:
`poetry python -m gunicorn ttxbackend.asgi:application -k uvicorn.workers.UvicornWorker`
This will start a gunicorn server using uvicorn workers. `uvicorn` is required because `gunicorn`
does not support websockets, which are necessary for GraphQL subscriptions.
Currently, it is not recommended to use **more than 1 worker**.
An additional environment variable `HOST_ADDRESSES` must be set with a comma-separated list of
allowed host addresses.
In Docker this can be done followingly:
```bash
docker run --name backend -e HOST_ADDRESSES="172.26.0.1,192.168.0.1" -p8000:8000 -d backend gunicorn
```
### Formatting with Black
[Black](https://pypi.org/project/black/) is included in the poetry environment and configured appropriately.
To reformat code run `black .`, to check if your code is formatted properly run `black . --check` .
......
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
from rest_framework.exceptions import AuthenticationFailed
from common_lib.result import Result, Ok, Err
from user.models import User
from common_lib.logger import log_user_msg, logger
UserModel = get_user_model()
......@@ -10,23 +11,37 @@ UserModel = get_user_model()
class CustomAuthBackend(ModelBackend):
def authenticate(
self, request, username=None, password=None, **kwargs
) -> Result[User, str]:
) -> User:
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
if username is None or password is None:
return Err("Username or password is missing")
logger.info(
log_user_msg(request)
+ "Failed login - missing credentials login"
)
raise AuthenticationFailed("Username or password is missing")
try:
user = UserModel._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user.
UserModel().set_password(password)
return Err("Incorrect username or password")
logger.info(
log_user_msg(request) + "Failed login - incorrect email"
)
raise AuthenticationFailed("Incorrect username or password")
else:
if not user.check_password(password):
return Err("Incorrect username or password")
logger.info(
log_user_msg(request) + "Failed login - incorrect password"
)
raise AuthenticationFailed("Incorrect username or password")
if not self.user_can_authenticate(user):
return Err("Your account has been blocked")
logger.info(
log_user_msg(request)
+ "Failed login - deactivated user login attempt"
)
raise AuthenticationFailed("Your account has been blocked")
return Ok(user)
return user
import graphene
from django.contrib.auth import authenticate, logout, login
from rest_framework.exceptions import AuthenticationFailed
from django.conf import settings
from django.core.exceptions import ValidationError
from common_lib.schema_types import UserType
from common_lib.result import Result
from common_lib.logger import logger, log_user_msg
from common_lib.exceptions import ValidationError
from user.email.email_sender import send_password_change_notification
from user.models import User
from aai.utils import logged_in
......@@ -27,17 +26,11 @@ class LoginMutation(graphene.Mutation):
username: str,
password: str,
) -> graphene.Mutation:
user_result: Result[User, str] = authenticate(
username=username, password=password
)
if user_result.is_err():
logger.info(log_user_msg(info.context, username) + "failed login")
raise AuthenticationFailed(user_result.unwrap_err())
login(info.context, user_result.unwrap())
user: User = authenticate(username=username, password=password)
login(info.context, user)
logger.info(log_user_msg(info.context) + "successful login")
# TODO: implement attempts limiter
return LoginMutation(user=user_result.unwrap())
return LoginMutation(user=user)
class LogoutMutation(graphene.Mutation):
......@@ -46,7 +39,6 @@ class LogoutMutation(graphene.Mutation):
@classmethod
@logged_in
def mutate(cls, root, info) -> graphene.Mutation:
user = info.context.user
logger.info(log_user_msg(info.context) + "logged out")
logout(info.context)
return LogoutMutation(logged_out=True)
......@@ -74,7 +66,7 @@ class PasswordChange(graphene.Mutation):
if not user.check_password(old_password):
logger.info(log_user_msg(info.context) + "failed password change")
raise AuthenticationFailed("Old password does not match")
raise ValidationError("Old password does not match")
if new_password != new_password_repeat:
logger.info(log_user_msg(info.context) + "failed password change")
......@@ -87,7 +79,8 @@ class PasswordChange(graphene.Mutation):
)
user.set_password(new_password)
user.save()
send_password_change_notification(user)
if not settings.DEBUG and not settings.TESTING_MODE:
send_password_change_notification(user)
logger.info(log_user_msg(info.context) + "successful password change")
return PasswordChange(password_changed=True)
......
......@@ -19,6 +19,8 @@ from running_exercise.graphql_inputs import (
)
from exercise.graphql_inputs import CreateExerciseInput
INSTRUCTOR = -1
class Check(str, Enum):
TEAM_ID = "team_id"
......@@ -92,10 +94,16 @@ def _get_param_id_from_input_obj(
id_parameter = input_obj.team_id
elif isinstance(input_obj, SendEmailInput):
# TODO: this needs rework
thread = get_model(EmailThread, id=input_obj.thread_id)
id_parameter = EmailParticipant.objects.get(
participant_team = EmailParticipant.objects.get(
address=input_obj.sender_address, exercise_id=thread.exercise_id
).team_id
).team
id_parameter = (
participant_team.team_id
if participant_team is not None
else INSTRUCTOR
)
elif isinstance(input_obj, CreateExerciseInput):
id_parameter = input_obj.definition_id
......@@ -178,7 +186,7 @@ def protected(required_permission: str):
def decorator(func):
def wrapper(*args, **kwargs):
if settings.INJECT_NOAUTH:
if settings.NOAUTH:
return func(*args, **kwargs)
user = _checked_get_user(*args, **kwargs)
......@@ -199,18 +207,19 @@ def _input_object_checks(
Returns:
True if one of the condition to access the data is met otherwise False.
"""
if isinstance(
input_obj, (UseToolInput, SelectTeamInjectInput, SendEmailInput)
):
if isinstance(input_obj, (UseToolInput, SelectTeamInjectInput)):
if isinstance(input_obj, UseToolInput):
exercise = get_model(Exercise, teams__in=[param_id])
# check if user has access to given tool (is in exercise
# containing the tool)
if not exercise.definition.tools.filter(id=param_id).exists():
return False
return _check_team(user, param_id)
if _check_team(user, param_id):
return True
elif isinstance(input_obj, SendEmailInput):
if param_id == INSTRUCTOR:
return user.is_staff
return _check_team(user, param_id)
elif isinstance(input_obj, CreateExerciseInput):
return _check_definition(user, param_id)
......@@ -229,7 +238,7 @@ def input_object_protected(obj_name: str):
def decorator(func):
def wrapper(*args, **kwargs):
if settings.INJECT_NOAUTH:
if settings.NOAUTH:
return func(*args, **kwargs)
user = _checked_get_user(*args, **kwargs)
......@@ -256,7 +265,7 @@ def input_object_protected(obj_name: str):
def extra_protected(check: Check):
def decorator(func):
def wrapper(*args, **kwargs):
if settings.INJECT_NOAUTH:
if settings.NOAUTH:
return func(*args, **kwargs)
check_function = CHECK_MAPPING[check]
......
......@@ -9,6 +9,8 @@ from exercise.models import (
MilestoneState,
EmailParticipant,
TeamQuestionnaireState,
TeamLearningObjective,
TeamLearningActivity,
)
from exercise_definition.models import (
Milestone,
......@@ -26,6 +28,8 @@ from exercise_definition.models import (
Overlay,
Questionnaire,
Question,
LearningObjective,
LearningActivity,
)
from running_exercise.models import (
ActionLog,
......@@ -85,6 +89,9 @@ class DefinitionType(DjangoObjectType):
tools = graphene.List(graphene.NonNull(ToolType), required=True)
user_set = graphene.List(RestrictedUser)
def resolve_user_set(self, info):
return self.user_set.all()
class DefinitionRoleType(DjangoObjectType):
class Meta:
......@@ -97,7 +104,10 @@ class ExerciseType(DjangoObjectType):
# change the definition field type to hide some definition fields
definition = graphene.Field(ExerciseDefinitionType)
user_set = graphene.Field(RestrictedUser)
user_set = graphene.List(RestrictedUser)
def resolve_user_set(self, info):
return self.user_set.all()
class TeamType(DjangoObjectType):
......@@ -106,6 +116,9 @@ class TeamType(DjangoObjectType):
user_set = graphene.List(RestrictedUser)
def resolve_user_set(self, info):
return self.user_set.all()
class ContentType(DjangoObjectType):
class Meta:
......@@ -155,7 +168,10 @@ class QuestionType(DjangoObjectType):
def resolve_correct(self, info):
user = info.context.user
# very weird, but this should probably be resilient to AAI being turned off
if user.is_anonymous or user.group == UserGroup.TRAINEE:
if user.is_anonymous:
return self.correct
if user.group == UserGroup.TRAINEE:
return 0
return self.correct
......@@ -349,3 +365,36 @@ class TeamQuestionnaireStateType(DjangoObjectType):
class QuestionnaireAnswerType(DjangoObjectType):
class Meta:
model = QuestionnaireAnswer
def resolve_is_correct(self, info):
user = info.context.user
if user.is_anonymous:
return self.is_correct
if user.group == UserGroup.TRAINEE:
return None
return self.is_correct
class LearningObjectiveType(DjangoObjectType):
class Meta:
model = LearningObjective
class LearningActivityType(DjangoObjectType):
class Meta:
model = LearningActivity
class TeamLearningObjectiveType(DjangoObjectType):
reached = graphene.Boolean(source="reached")
class Meta:
model = TeamLearningObjective
class TeamLearningActivityType(DjangoObjectType):
reached = graphene.Boolean(source="reached")
class Meta:
model = TeamLearningActivity
## 0.8.0
Add a new required concept called `learning objectives`.
This concept _must_ be included in all exercise definition. #170
### objectives.yml
- added new file
### milestones.yml
- add field `activity`
## 0.7.0
Add simple questionnaires. #165
......
......@@ -27,6 +27,7 @@ The current definition version the backend supports is the first version in the
- tools.yml
- roles.yml
- questionnaires.yml
- objectives.yml
```
The `content` and `files` directories are optional, they do not need to be present if your definition does not use
......@@ -61,6 +62,7 @@ specified, or it is stated otherwise.
- [email.yml](#emailyml)
- [roles.yml](#rolesyml)
- [questionnaires.yml](#questionnairesyml)
- [objectives.yml](#objectivesyml)
### Common definitions
This section contains definitions of common fields used by multiple definition files.
......@@ -97,6 +99,16 @@ Changes the domain of email addresses of teams, e.g. if set to `gmail.com` team
- **enable_roles**: _bool, default=False_ - Enables use of team roles. Requires `roles.yml` file to be included in the definition.
- **version**: _string_ - semver version string of the format this definition uses, see [versioning](#versioning) for details.
### objectives.yml
This file contains all learning objectives. Each learning objective has the following fields:
- **name**: _string, unique_ - name of the learning objective
- **tags**: _string, default=""_ - a comma-separated list of tags corresponding to this objective
- **activities**: - list of learning activities
- **name**: _string, unique_ - name of the learning activity
- **tags**: _string, default=""_ - a comma-separated list of tags corresponding to this activity
### channels.yml
This file contains the definitions of _all_ channels used in the exercise.
- **name**: _string_ - the name that will be displayed to users
......@@ -113,6 +125,7 @@ In other words, there cannot be multiple channels with type `email`.
If a definition does not contain a channel with the `tool` type, _no tools_ can be specified.
Same rule applies to emails.
### injects.yml
This file contains a list of injects. Each inject has the following fields:
- **name**: _string, unique_ - name of the inject
......@@ -167,6 +180,7 @@ only the **first** matched response is sent back.
- **content**: _[content](#content), default=empty_
- **control**: _[control](#control), default=empty_
### milestones.yml
This file contains all milestones in an exercise. Any `milestone_condition` or `(de)activate_milestone`
field in the whole definition can only contain milestones defined in here. Each milestone has the following fields:
......@@ -186,6 +200,7 @@ can be specified as a space separated list. If at least one of these files is do
- **final**: _bool, default=False_ - sets this milestone as a final milestone. If a team reaches this
milestone, their exercise is considered to be finished. There _must_ be at least _one_ milestone,
however there can be _more than one_.
- **activity**: _string, default=""_ - learning activity linked to this milestone
### email.yml
......@@ -205,6 +220,7 @@ This file contains all email addresses where each address has the following fiel
Required if roles are enabled. This file contains all role names:
- **name**: _string, unique_ - Name of the role.
### questionnaires.yml
This file contains all questionnaires. Each questionnaire has the following fields:
- **title**: _string_ - title of the questionnaire
......@@ -215,8 +231,8 @@ after which the questionnaire will be sent
- **questions**:
- **text**: _str, required_ - the text of the question
- **max**: _int, required_ - the number of options for the question
- **labels**: _str, default=""_ - the labels of options specified as a comma-separated string (`, `),
empty string means the labels will be numbers from in range [1-max]
- **labels**: _str, default=""_ - the labels of options specified as a comma-separated string (`,`),
empty string means the labels will be numbers in range [1-max]
- **correct**: _int, default=0_ - position which represents the correct choice, for example,
if we have 4 labels `yes, no, maybe, absolutely`, the `correct` field should have the number `3`
if the correct choice is `maybe`, `0` means the question has no correct choice
......
exercise_duration: 60
show_exercise_time: False
version: 0.7.0
version: 0.8.0
- name: website_visited
team_visible: True
activity: Visit website
- name: website_traffic_blocked
team_visible: True
activity: Download file and block website
- name: evaluation_great
final: True
......@@ -18,8 +20,11 @@
- name: file_downloaded
team_visible: True
file_names: file1.txt file2.txt
activity: Download file and block website
- name: questionnaire_answered
activity: Answer the sent questionnaire
- name: likes_apples
team_visible: True
activity: Answer the sent questionnaire
- name: First learning objective
tags: analyze, apply, create, prioritize
activities:
- name: Visit website
tags: analyze, prioritize
- name: Download file and block website
tags: download, block
- name: Answer questionnaire
tags: answer
activities:
- name: Answer the sent questionnaire
tags: answer
exercise_duration: 60
email_between_teams: True
show_exercise_time: False
version: 0.7.0
version: 0.8.0
- name: website_visited
team_visible: True
activity: Visit website
- name: website_traffic_blocked
team_visible: True
activity: Download file and block website
- name: email_sent_to_test
......@@ -21,14 +23,14 @@
- name: inject_selection
- name: hint_provided
team_visible: True
- name: file_downloaded
team_visible: True
file_names: file1.txt file2.txt
activity: Download file and block website
- name: questionnaire_answered
activity: Answer the sent questionnaire
- name: likes_apples
team_visible: True
activity: Answer the sent questionnaire
- name: First learning objective
tags: analyze, apply, create, prioritize
activities:
- name: Visit website
tags: analyze, prioritize
- name: Download file and block website
tags: download, block
- name: Answer questionnaire
tags: answer
activities:
- name: Answer the sent questionnaire
tags: answer
exercise_duration: 60
show_exercise_time: False
enable_roles: True
version: 0.7.0
version: 0.8.0
- name: website_visited
team_visible: True
activity: Visit website
- name: evaluation_great
......@@ -11,6 +12,8 @@
- name: file_downloaded
team_visible: True
file_names: file1.txt file2.txt
activity: Download file and block website
- name: likes_apples
team_visible: True
activity: Answer the sent questionnaire
......@@ -2,6 +2,7 @@
team_visible: True
final: True
roles: role_3
activity: Download file and block website
- name: first_inject
team_visible: True
......@@ -10,7 +11,9 @@
- name: r1r2questionnaire_answered
team_visible: True
roles: role_1 role_2
activity: Answer the sent questionnaire
- name: r3questionnaire_answered
team_visible: True
roles: role_3
activity: Answer the sent questionnaire
- name: First learning objective
tags: analyze, apply, create, prioritize
activities:
- name: Visit website
tags: analyze, prioritize
- name: Download file and block website
tags: download, block
- name: Answer questionnaire
tags: answer
activities:
- name: Answer the sent questionnaire
tags: answer
import os.path
from typing import List, Optional, TypeVar, Tuple, Union
from typing import List, Optional, TypeVar, Tuple, Union, Dict
from django.conf import settings
from django.utils import timezone
......@@ -21,6 +21,8 @@ from exercise.models import (
TeamState,
EmailParticipant,
TeamQuestionnaireState,
TeamLearningObjective,
TeamLearningActivity,
)
from exercise_definition.models import (
Milestone,
......@@ -174,9 +176,14 @@ def _create_teams_basic(
team_names = [f"team-{i+1}" for i in range(team_count)]
for team_name in team_names:
team_state = _create_state(exercise, milestones)
state = TeamState.objects.create(exercise=exercise)
activities = _create_team_learning_objectives(
state, exercise.definition
)
_create_milestone_states(state, milestones, activities)
team = Team.objects.create(
exercise=exercise, name=team_name, state=team_state
exercise=exercise, name=team_name, state=state
)
teams.append(team)
......@@ -191,7 +198,7 @@ def _create_teams_roles(exercise: Exercise, team_count: int) -> List[Team]:
# we know that the number of teams is divisible by the role count
for team_set in range(team_count // roles.count()):
state = _create_state(exercise, milestones)
state = TeamState.objects.create(exercise=exercise)
for role in roles:
team = Team.objects.create(
exercise=exercise,
......@@ -201,22 +208,66 @@ def _create_teams_roles(exercise: Exercise, team_count: int) -> List[Team]:
)
teams.append(team)
activities = _create_team_learning_objectives(
state, exercise.definition
)
_create_milestone_states(state, milestones, activities)
return teams
def _create_state(exercise: Exercise, milestones: List[Milestone]) -> TeamState:
team_state = TeamState.objects.create(exercise=exercise)
for milestone in milestones:
milestone_state = MilestoneState.objects.create(
team_state=team_state, milestone=milestone
def _create_team_learning_objectives(
state: TeamState, definition: Definition
) -> Dict[int, TeamLearningActivity]:
# mapping of definition activity id to team learning activity
activities: Dict[int, TeamLearningActivity] = dict()
# really sad, but our version of django does not retrieve ids of bulk_created models
TeamLearningObjective.objects.bulk_create(
map(
lambda objective: TeamLearningObjective(
team_state=state, objective=objective
),
definition.learning_objectives.all(),
)
history = MilestoneStateHistory.objects.create(
milestone_state=milestone_state,
reached=milestone_state.reached,
timestamp_from=timezone.now(),
)
# which means we have to query the database again
for tlo in state.learning_objectives.all():
for activity in tlo.objective.activities.all():
tla = TeamLearningActivity.objects.create(
activity=activity, team_objective=tlo
)
activities[activity.id] = tla
return activities
def _create_milestone_states(
state: TeamState,
milestones: List[Milestone],
activities: Dict[int, TeamLearningActivity],
):
MilestoneState.objects.bulk_create(
map(
lambda milestone: MilestoneState(
team_state=state,
milestone=milestone,
activity=activities.get(milestone.activity_id), # type: ignore
),
milestones,
)
history.save()
return team_state
)
MilestoneStateHistory.objects.bulk_create(
map(
lambda milestone_state: MilestoneStateHistory(
milestone_state=milestone_state,
reached=milestone_state.reached,
timestamp_from=timezone.now(),
),
state.milestone_states.all(),
)
)
def _create_team_inject_states(teams: List[Team], definition: Definition):
......
# Generated by Django 3.2.22 on 2024-04-18 17:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('exercise_definition', '0003_auto_20240418_1132'),
('exercise', '0002_teamquestionnairestate'),
]
operations = [
migrations.CreateModel(
name='TeamLearningObjective',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('objective', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='exercise_definition.learningobjective')),
('team_state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='learning_objectives', to='exercise.teamstate')),
],
),
migrations.CreateModel(
name='TeamLearningActivity',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='exercise_definition.learningactivity')),
('team_objective', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to='exercise.teamlearningobjective')),
],
),
migrations.AddField(
model_name='milestonestate',
name='activity',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='milestone_states', to='exercise.teamlearningactivity'),
),
]