diff --git a/.gitignore b/.gitignore index 93944dc..7328249 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,4 @@ prod_backup*.sql setup learning.service logs/ -.env +*.env diff --git a/LearningAPI/migrations/0052_studentteam_nssuserteam_groupprojectrepository.py b/LearningAPI/migrations/0052_studentteam_nssuserteam_groupprojectrepository.py new file mode 100644 index 0000000..d76bab9 --- /dev/null +++ b/LearningAPI/migrations/0052_studentteam_nssuserteam_groupprojectrepository.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.14 on 2024-08-28 20:03 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0051_add_student_note_type_to_database_function"), + ] + + operations = [ + migrations.CreateModel( + name="StudentTeam", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("group_name", models.CharField(max_length=55, unique=True)), + ("sprint_team", models.BooleanField(default=False)), + ( + "cohort", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.cohort", + ), + ), + ], + ), + migrations.CreateModel( + name="NSSUserTeam", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "student", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.nssuser", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.studentteam", + ), + ), + ], + ), + migrations.CreateModel( + name="GroupProjectRepository", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("repository_url", models.URLField()), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.project", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.studentteam", + ), + ), + ], + ), + ] diff --git a/LearningAPI/migrations/0052_studentteam_nssuserteam_groupprojectrepository_squashed_0060_studentteam_slack_channel.py b/LearningAPI/migrations/0052_studentteam_nssuserteam_groupprojectrepository_squashed_0060_studentteam_slack_channel.py new file mode 100644 index 0000000..bf9e357 --- /dev/null +++ b/LearningAPI/migrations/0052_studentteam_nssuserteam_groupprojectrepository_squashed_0060_studentteam_slack_channel.py @@ -0,0 +1,138 @@ +# Generated by Django 4.2.14 on 2024-09-03 01:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + replaces = [ + ("LearningAPI", "0052_studentteam_nssuserteam_groupprojectrepository"), + ("LearningAPI", "0053_alter_studentteam_group_name"), + ("LearningAPI", "0054_alter_nssuserteam_student_alter_nssuserteam_team"), + ("LearningAPI", "0055_studentteam_members_alter_nssuserteam_student_and_more"), + ("LearningAPI", "0056_rename_members_studentteam_students"), + ("LearningAPI", "0057_project_template_url"), + ( + "LearningAPI", + "0058_remove_project_template_url_project_api_template_url_and_more", + ), + ("LearningAPI", "0059_remove_groupprojectrepository_repository_url"), + ("LearningAPI", "0060_studentteam_slack_channel"), + ] + + dependencies = [ + ("LearningAPI", "0051_add_student_note_type_to_database_function"), + ] + + operations = [ + migrations.CreateModel( + name="StudentTeam", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("group_name", models.CharField(max_length=55)), + ("sprint_team", models.BooleanField(default=False)), + ( + "cohort", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.cohort", + ), + ), + ], + ), + migrations.CreateModel( + name="NSSUserTeam", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "student", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.nssuser", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="members", + to="LearningAPI.studentteam", + ), + ), + ], + ), + migrations.AddField( + model_name="studentteam", + name="students", + field=models.ManyToManyField( + through="LearningAPI.NSSUserTeam", to="LearningAPI.nssuser" + ), + ), + migrations.AlterField( + model_name="nssuserteam", + name="team", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.studentteam", + ), + ), + migrations.AddField( + model_name="project", + name="api_template_url", + field=models.URLField(default=""), + ), + migrations.AddField( + model_name="project", + name="client_template_url", + field=models.URLField(default=""), + ), + migrations.CreateModel( + name="GroupProjectRepository", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.project", + ), + ), + ( + "team", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.studentteam", + ), + ), + ], + ), + migrations.AddField( + model_name="studentteam", + name="slack_channel", + field=models.CharField(default="", max_length=55), + ), + ] diff --git a/LearningAPI/migrations/0053_alter_studentteam_group_name.py b/LearningAPI/migrations/0053_alter_studentteam_group_name.py new file mode 100644 index 0000000..51fc346 --- /dev/null +++ b/LearningAPI/migrations/0053_alter_studentteam_group_name.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.14 on 2024-08-28 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0052_studentteam_nssuserteam_groupprojectrepository"), + ] + + operations = [ + migrations.AlterField( + model_name="studentteam", + name="group_name", + field=models.CharField(max_length=55), + ), + ] diff --git a/LearningAPI/migrations/0054_alter_nssuserteam_student_alter_nssuserteam_team.py b/LearningAPI/migrations/0054_alter_nssuserteam_student_alter_nssuserteam_team.py new file mode 100644 index 0000000..404cfc2 --- /dev/null +++ b/LearningAPI/migrations/0054_alter_nssuserteam_student_alter_nssuserteam_team.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.14 on 2024-08-28 20:47 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0053_alter_studentteam_group_name"), + ] + + operations = [ + migrations.AlterField( + model_name="nssuserteam", + name="student", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="teams", + to="LearningAPI.nssuser", + ), + ), + migrations.AlterField( + model_name="nssuserteam", + name="team", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="members", + to="LearningAPI.studentteam", + ), + ), + ] diff --git a/LearningAPI/migrations/0055_studentteam_members_alter_nssuserteam_student_and_more.py b/LearningAPI/migrations/0055_studentteam_members_alter_nssuserteam_student_and_more.py new file mode 100644 index 0000000..64a2ce3 --- /dev/null +++ b/LearningAPI/migrations/0055_studentteam_members_alter_nssuserteam_student_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.14 on 2024-08-28 20:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0054_alter_nssuserteam_student_alter_nssuserteam_team"), + ] + + operations = [ + migrations.AddField( + model_name="studentteam", + name="members", + field=models.ManyToManyField( + through="LearningAPI.NSSUserTeam", to="LearningAPI.nssuser" + ), + ), + migrations.AlterField( + model_name="nssuserteam", + name="student", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="LearningAPI.nssuser" + ), + ), + migrations.AlterField( + model_name="nssuserteam", + name="team", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="LearningAPI.studentteam", + ), + ), + ] diff --git a/LearningAPI/migrations/0056_rename_members_studentteam_students.py b/LearningAPI/migrations/0056_rename_members_studentteam_students.py new file mode 100644 index 0000000..f47d758 --- /dev/null +++ b/LearningAPI/migrations/0056_rename_members_studentteam_students.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.14 on 2024-08-28 20:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0055_studentteam_members_alter_nssuserteam_student_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="studentteam", + old_name="members", + new_name="students", + ), + ] diff --git a/LearningAPI/migrations/0057_project_template_url.py b/LearningAPI/migrations/0057_project_template_url.py new file mode 100644 index 0000000..a61cf16 --- /dev/null +++ b/LearningAPI/migrations/0057_project_template_url.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.14 on 2024-08-29 19:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0056_rename_members_studentteam_students"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="template_url", + field=models.CharField(default="", max_length=256), + ), + ] diff --git a/LearningAPI/migrations/0058_remove_project_template_url_project_api_template_url_and_more.py b/LearningAPI/migrations/0058_remove_project_template_url_project_api_template_url_and_more.py new file mode 100644 index 0000000..1c5ca90 --- /dev/null +++ b/LearningAPI/migrations/0058_remove_project_template_url_project_api_template_url_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.14 on 2024-08-29 19:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0057_project_template_url"), + ] + + operations = [ + migrations.RemoveField( + model_name="project", + name="template_url", + ), + migrations.AddField( + model_name="project", + name="api_template_url", + field=models.URLField(default=""), + ), + migrations.AddField( + model_name="project", + name="client_template_url", + field=models.URLField(default=""), + ), + ] diff --git a/LearningAPI/migrations/0059_remove_groupprojectrepository_repository_url.py b/LearningAPI/migrations/0059_remove_groupprojectrepository_repository_url.py new file mode 100644 index 0000000..7af82c1 --- /dev/null +++ b/LearningAPI/migrations/0059_remove_groupprojectrepository_repository_url.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.14 on 2024-08-29 20:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ( + "LearningAPI", + "0058_remove_project_template_url_project_api_template_url_and_more", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="groupprojectrepository", + name="repository_url", + ), + ] diff --git a/LearningAPI/migrations/0060_studentteam_slack_channel.py b/LearningAPI/migrations/0060_studentteam_slack_channel.py new file mode 100644 index 0000000..db3c926 --- /dev/null +++ b/LearningAPI/migrations/0060_studentteam_slack_channel.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.14 on 2024-08-29 20:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0059_remove_groupprojectrepository_repository_url"), + ] + + operations = [ + migrations.AddField( + model_name="studentteam", + name="slack_channel", + field=models.CharField(default="", max_length=55), + ), + ] diff --git a/LearningAPI/migrations/0061_groupprojectrepository_repository.py b/LearningAPI/migrations/0061_groupprojectrepository_repository.py new file mode 100644 index 0000000..e566496 --- /dev/null +++ b/LearningAPI/migrations/0061_groupprojectrepository_repository.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.14 on 2024-10-02 11:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "LearningAPI", + "0052_studentteam_nssuserteam_groupprojectrepository_squashed_0060_studentteam_slack_channel", + ), + ] + + operations = [ + migrations.AddField( + model_name="groupprojectrepository", + name="repository", + field=models.CharField(default="", max_length=255), + ), + ] diff --git a/LearningAPI/migrations/0062_alter_groupprojectrepository_team.py b/LearningAPI/migrations/0062_alter_groupprojectrepository_team.py new file mode 100644 index 0000000..57e6e5a --- /dev/null +++ b/LearningAPI/migrations/0062_alter_groupprojectrepository_team.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.14 on 2024-10-02 16:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("LearningAPI", "0061_groupprojectrepository_repository"), + ] + + operations = [ + migrations.AlterField( + model_name="groupprojectrepository", + name="team", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="repositories", + to="LearningAPI.studentteam", + ), + ), + ] diff --git a/LearningAPI/models/coursework/project.py b/LearningAPI/models/coursework/project.py index 340d073..cc5e047 100644 --- a/LearningAPI/models/coursework/project.py +++ b/LearningAPI/models/coursework/project.py @@ -5,8 +5,9 @@ class Project(models.Model): """Course projects""" name = models.CharField(max_length=55) implementation_url = models.CharField(max_length=256) - book = models.ForeignKey( - "Book", on_delete=models.CASCADE, related_name="child_projects") + client_template_url = models.URLField(default="") + api_template_url = models.URLField(default="") + book = models.ForeignKey("Book", on_delete=models.CASCADE, related_name="child_projects") index = models.IntegerField(default=0) active = models.BooleanField(default=True) is_group_project = models.BooleanField(default=False) diff --git a/LearningAPI/models/people/__init__.py b/LearningAPI/models/people/__init__.py index fc3fce4..df41ead 100644 --- a/LearningAPI/models/people/__init__.py +++ b/LearningAPI/models/people/__init__.py @@ -12,4 +12,7 @@ from .student_note import StudentNote from .student_personality import StudentPersonality from .student_tag import StudentTag -from .student_note_type import StudentNoteType \ No newline at end of file +from .student_note_type import StudentNoteType +from .student_team import StudentTeam +from .nssuser_team import NSSUserTeam +from .group_project_repo import GroupProjectRepository \ No newline at end of file diff --git a/LearningAPI/models/people/group_project_repo.py b/LearningAPI/models/people/group_project_repo.py new file mode 100644 index 0000000..8adae4c --- /dev/null +++ b/LearningAPI/models/people/group_project_repo.py @@ -0,0 +1,20 @@ +from django.db import models +from django.core.exceptions import ValidationError + +class GroupProjectRepository(models.Model): + """ This class stores the repository URLs for a group project """ + team = models.ForeignKey("StudentTeam", on_delete=models.CASCADE, related_name="repositories") + project = models.ForeignKey("Project", on_delete=models.CASCADE) + repository = models.CharField(max_length=255, default="") + + def clean(self): + super().clean() + if not self.project.is_group_project and self.project_id.isdigit(): + raise ValidationError({ + 'project': 'Integer IDs are only allowed for projects with is_group_project set to True.' + }) + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + diff --git a/LearningAPI/models/people/nssuser.py b/LearningAPI/models/people/nssuser.py index fdd0899..8468bab 100644 --- a/LearningAPI/models/people/nssuser.py +++ b/LearningAPI/models/people/nssuser.py @@ -25,6 +25,10 @@ def __str__(self) -> str: def full_name(self): return f'{self.user.first_name} {self.user.last_name}' + @property + def name(self): + return f'{self.user.first_name} {self.user.last_name}' + @property def score(self): """Return total learning score""" diff --git a/LearningAPI/models/people/nssuser_team.py b/LearningAPI/models/people/nssuser_team.py new file mode 100644 index 0000000..8982cee --- /dev/null +++ b/LearningAPI/models/people/nssuser_team.py @@ -0,0 +1,7 @@ +from django.db import models + +class NSSUserTeam(models.Model): + """ This class is used to store the relationship between a student and a team """ + team = models.ForeignKey("StudentTeam", on_delete=models.CASCADE) + student = models.ForeignKey("NSSUser", on_delete=models.CASCADE) + diff --git a/LearningAPI/models/people/student_team.py b/LearningAPI/models/people/student_team.py new file mode 100644 index 0000000..fb1006b --- /dev/null +++ b/LearningAPI/models/people/student_team.py @@ -0,0 +1,9 @@ +from django.db import models + +class StudentTeam(models.Model): + """ This class is used to create a student team for a cohort """ + group_name = models.CharField(max_length=55) + cohort = models.ForeignKey("Cohort", on_delete=models.CASCADE) + sprint_team = models.BooleanField(default=False) + slack_channel = models.CharField(max_length=55, default="") + students = models.ManyToManyField("NSSUser", through="NSSUserTeam") diff --git a/LearningAPI/utils.py b/LearningAPI/utils.py index a1788dd..ef2cce9 100644 --- a/LearningAPI/utils.py +++ b/LearningAPI/utils.py @@ -1,9 +1,89 @@ -import json -import time -import os -import requests +import json, random, string, time, os, logging, requests from requests.exceptions import ConnectionError +from LearningAPI.models.people import NssUser + +class SlackAPI(object): + """ This class is used to create a Slack channel for a student team """ + def __init__(self): + self.headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + def send_message(self, channel, text): + # Configure the config for the Slack message + channel_payload = { + "text": text, + "token": os.getenv("SLACK_BOT_TOKEN"), + "channel": channel + } + + response = requests.post( + url="https://slack.com/api/chat.postMessage", + data=channel_payload, + headers=self.headers, + timeout=10 + ) + return response.json() + + + def delete_channel(self, channel_id): + channel_payload = { + "channel": channel_id, + "token": os.getenv("SLACK_BOT_TOKEN") + } + + # Create a Slack channel with the given name + res = requests.post( + "https://slack.com/api/conversations.archive", + timeout=10, + data=channel_payload, + headers=self.headers + ) + channel_res = res.json() + return channel_res['ok'] + + def create_channel(self, name, members): + """Create a Slack channel for a student team""" + channel_payload = { + "name": name, + "token": os.getenv("SLACK_BOT_TOKEN") + } + + # Create a Slack channel with the given name + res = requests.post( + "https://slack.com/api/conversations.create", + timeout=10, + data=channel_payload, + headers=self.headers + ) + channel_res = res.json() + + # Create a set of Slack IDs for the members to be added to the channel + member_slack_ids = set() + for member_id in members: + member = NssUser.objects.get(pk=member_id) + if member.slack_handle is not None: + member_slack_ids.add(member.slack_handle) + + # Create a payload to invite students and instructors to the channel + invitation_payload = { + "channel": channel_res["channel"]["id"], + "users": ",".join(list(member_slack_ids)), + "token": os.getenv("SLACK_BOT_TOKEN") + } + + # Invite students and instructors to the channel + requests.post( + "https://slack.com/api/conversations.invite", + timeout=10, + data=invitation_payload, + headers=self.headers + ) + + # Return the channel ID for the team + return channel_res["channel"]["id"] + class GithubRequest(object): def __init__(self): @@ -15,23 +95,82 @@ def __init__(self): "Authorization": f'Bearer {os.getenv("GITHUB_TOKEN")}' } + def create_repository(self, source_url: str, student_org_url: str, repo_name: str, project_name: str) -> requests.Response: + """Create a repository for a student team + + Args: + source_url (str): The URL of the source repository + student_org_url (str): The URL of the student organization + repo_name (str): The name of the repository + project_name (str): The name of the project + + Returns: + requests.Response: The response from the GitHub API + """ + + # Split the full URL on '/' and get the last two items + ( org, repo, ) = source_url.split('/')[-2:] + + student_org_name = student_org_url.split("/")[-1] + + # Construct request body for creating the repository + request_body = { + "owner": student_org_name, + "name": repo_name, + "description": f"This is your client-side repository for the {project_name} sprint(s).", + "include_all_branches": False, + "private": False + } + + # Create the repository + response = self.post(url=f'https://api.github.com/repos/{org}/{repo}/generate', data=request_body) + + return response + + def assign_student_permissions(self, student_org_name: str, repo_name: str, student: NssUser, permission: str = "write") -> requests.Response: + """Assign write permissions to a student for a repository + + Args: + student_org_name (str): The name of the student organization + repo_name (str): The name of the repository + student (NSSUser): The student to assign permissions to + + Returns: + requests.Response: The response from the GitHub API + """ + + # Construct request body for assigning permissions to the student + request_body = { "permission":permission } + + # Assign the student write permissions to the repository + response = self.put( + url=f'https://api.github.com/repos/{student_org_name}/{repo_name}/collaborators/{student.github_handle}', + data=request_body + ) + + if response.status_code != 204: + logger = logging.getLogger("LearningPlatform") + logger.exception( + "Error: %s was not added as a collaborator to the assessment repository.", + student.full_name + ) + + return response + + def get(self, url): - return self.request_with_retry( - lambda: requests.get(url=url, headers=self.headers)) + return self.request_with_retry(lambda: requests.get(url=url, headers=self.headers, timeout=10)) def put(self, url, data): json_data = json.dumps(data) - return self.request_with_retry( - lambda: requests.put(url=url, data=json_data, headers=self.headers)) + return self.request_with_retry(lambda: requests.put(url=url, data=json_data, headers=self.headers, timeout=10)) def post(self, url, data): json_data = json.dumps(data) try: - result = self.request_with_retry( - lambda: requests.post(url=url, data=json_data, headers=self.headers)) - + result = self.request_with_retry(lambda: requests.post(url=url, data=json_data, headers=self.headers, timeout=10)) return result except TimeoutError: @@ -64,3 +203,4 @@ def sleep_with_countdown(self, countdown_seconds): if count: time.sleep(0.5) + diff --git a/LearningAPI/views/__init__.py b/LearningAPI/views/__init__.py index 2a3c6b5..ce019b5 100644 --- a/LearningAPI/views/__init__.py +++ b/LearningAPI/views/__init__.py @@ -16,8 +16,6 @@ from .github_login import GithubLogin from .student_assessment import StudentAssessmentView from .assessment_status import AssessmentStatusView -from .slack import SlackChannel -from .slack_message import SlackMessage from .core_skill_view import CoreSkillViewSet from .core_skill_record_view import CoreSkillRecordViewSet from .student_personality_view import StudentPersonalityViewSet @@ -28,4 +26,5 @@ from .student_note_view import StudentNoteViewSet from .personality_view import PersonalityView from .book_assessment import BookAssessmentView -from .student_note_type_view import StudentNoteTypeViewSet \ No newline at end of file +from .student_note_type_view import StudentNoteTypeViewSet +from .team_maker_view import TeamMakerView \ No newline at end of file diff --git a/LearningAPI/views/notify.py b/LearningAPI/views/notify.py index 719459e..c9cfc23 100644 --- a/LearningAPI/views/notify.py +++ b/LearningAPI/views/notify.py @@ -1,35 +1,7 @@ -import os -import requests from rest_framework.decorators import api_view from rest_framework.response import Response from LearningAPI.models.people import NssUser - -def slack_notify(message, channel): - """ - Sends a notification message to a Slack channel. - - Args: - message (str): The message to send. - channel (str): The Slack channel to send the message to. - - Raises: - requests.exceptions.Timeout: If the request to the Slack API times out. - """ - - headers = { - "Content-Type": "application/x-www-form-urlencoded" - } - - requests.post( - "https://slack.com/api/chat.postMessage", - data={ - "text": message, - "token": os.getenv("SLACK_BOT_TOKEN"), - "channel": channel - }, - headers=headers, - timeout=10 - ) +from LearningAPI.utils import SlackAPI @api_view(['POST']) def notify(request): @@ -51,10 +23,20 @@ def notify(request): requests.exceptions.Timeout: If the request to the Slack API times out. """ - student = NssUser.objects.get(user=request.auth.user) - slack_channel = student.assigned_cohorts.order_by("-id").first().cohort.slack_channel + instructors = request.data.get("instructors", None) + student_channel = request.data.get("studentChannel", None) + message = request.data.get("message", None) + + if instructors: + # Get the cohort's instrutor Slack channel + target_user = NssUser.objects.get(user=request.auth.user) + slack_channel = target_user.assigned_cohorts.order_by("-id").first().cohort.slack_channel + SlackAPI().send_message( text=message, channel=slack_channel ) + return Response({ 'message': 'Notification sent to instructor channel'}, status=200) - message = request.data.get("message") - slack_notify(message, slack_channel) + elif student_channel is not None: + slack_channel = target_user.assigned_cohorts.order_by("-id").first().cohort.slack_channel + SlackAPI().send_message( text=message, channel=student_channel ) + return Response({ 'message': 'Notification sent to student'}, status=200) - return Response({ 'message': 'Notification sent to Slack!'}, status=200) + return Response({ 'message': 'Invalid request'}, status=400) diff --git a/LearningAPI/views/project_view.py b/LearningAPI/views/project_view.py index 944a849..df3676d 100644 --- a/LearningAPI/views/project_view.py +++ b/LearningAPI/views/project_view.py @@ -22,6 +22,8 @@ def create(self, request): project.index = request.data["index"] project.active = True project.is_group_project = request.data["is_group_project"] + project.api_template_url = request.data["api_template_url"] + project.client_template_url = request.data["client_template_url"] project.book = Book.objects.get(pk=request.data["book"]) project.implementation_url = request.data["implementation_url"] @@ -52,15 +54,15 @@ def update(self, request, pk=None): Returns: Response -- Empty body with 204 status code """ - url = request.data.get("implementation_url", "") - try: project = Project.objects.get(pk=pk) project.name = request.data["name"] project.active = request.data["active"] project.index = request.data["index"] project.is_group_project = request.data["is_group_project"] - project.implementation_url = url + project.api_template_url = request.data.get("api_template_url", "") + project.client_template_url = request.data.get("client_template_url", "") + project.implementation_url = request.data.get("implementation_url", "") project.save() except Project.DoesNotExist: @@ -97,6 +99,7 @@ def list(self, request): """ book_id = request.query_params.get("bookId", None) course_id = request.query_params.get("courseId", None) + group_projects = request.query_params.get("group", "false") try: projects = Project.objects.all().order_by('book__index', 'index') @@ -107,6 +110,9 @@ def list(self, request): if book_id is not None: projects = projects.filter(book__id=book_id) + if group_projects == "true": + projects = projects.filter(is_group_project=True) + serializer = ProjectSerializer(projects, many=True, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as ex: @@ -156,4 +162,5 @@ class Meta: fields = ( 'id', 'name', 'book', 'course', 'index', 'active', 'is_group_project', + 'api_template_url', 'client_template_url', ) diff --git a/LearningAPI/views/slack.py b/LearningAPI/views/slack.py deleted file mode 100644 index abf1b9c..0000000 --- a/LearningAPI/views/slack.py +++ /dev/null @@ -1,49 +0,0 @@ -"""View module for handling requests about park areas""" -import os -import requests -from rest_framework import status -from rest_framework.viewsets import ViewSet -from rest_framework.response import Response -from LearningAPI.models.people import NssUser - - -class SlackChannel(ViewSet): - """For creating Slack channels""" - - def create(self, request): - """Handle POST requests to create team Slack channels""" - - # Create the Slack channel - headers = { - "Content-Type": "application/x-www-form-urlencoded" - } - channel_payload = { - "name": request.data["name"], - "token": os.getenv("SLACK_BOT_TOKEN") - } - - student_slack_ids = set() - for student_id in request.data["students"]: - student = NssUser.objects.get(pk=student_id) - if student.slack_handle is not None: - student_slack_ids.add(student.slack_handle) - - res = requests.post("https://slack.com/api/conversations.create", timeout=10, data=channel_payload, headers=headers) - channel_res = res.json() - - # Add students to Slack channel - invitation_payload = { - "channel": channel_res["channel"]["id"], - "users": ",".join(list(student_slack_ids)), - "token": os.getenv("SLACK_BOT_TOKEN") - } - - res = requests.post("https://slack.com/api/conversations.invite", timeout=10, data=invitation_payload, headers=headers) - students_res = res.json() - - combined_response = { - "channel": channel_res, - "invitations": students_res - } - - return Response(combined_response, status=status.HTTP_201_CREATED) diff --git a/LearningAPI/views/slack_message.py b/LearningAPI/views/slack_message.py deleted file mode 100644 index e45177d..0000000 --- a/LearningAPI/views/slack_message.py +++ /dev/null @@ -1,35 +0,0 @@ -"""View module for handling requests about park areas""" -import os -import requests -from rest_framework import status -from rest_framework.viewsets import ViewSet -from rest_framework.response import Response - - -class SlackMessage(ViewSet): - """For creating Slack channels""" - - def create(self, request): - """Handle POST requests to create team Slack channels""" - - student_id = request.data.get("student", None) - - if student_id is not None: - - # Create the Slack channel - headers = { - "Content-Type": "application/x-www-form-urlencoded" - } - channel_payload = { - "text": request.data.get("text", "Test message"), - "token": os.getenv("SLACK_BOT_TOKEN"), - "channel": student_id - } - - res = requests.post( - "https://slack.com/api/chat.postMessage", data=channel_payload, headers=headers) - message_response = res.json() - - return Response(message_response, status=status.HTTP_201_CREATED) - - return Response(None, status=status.HTTP_400_BAD_REQUEST) diff --git a/LearningAPI/views/student_view.py b/LearningAPI/views/student_view.py index 8ddcb58..7cd39bf 100644 --- a/LearningAPI/views/student_view.py +++ b/LearningAPI/views/student_view.py @@ -14,17 +14,15 @@ from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from LearningAPI.utils import GithubRequest +from LearningAPI.utils import GithubRequest, SlackAPI from LearningAPI.decorators import is_instructor from LearningAPI.models import Tag from LearningAPI.models.coursework import StudentProject, Project, Capstone, CapstoneTimeline from LearningAPI.models.people import (StudentNote, NssUser, StudentAssessment, OneOnOneNote, StudentPersonality, Assessment, - StudentAssessmentStatus, StudentTag - ) + StudentAssessmentStatus, StudentTag) from LearningAPI.models.skill import (CoreSkillRecord, LearningRecord, LearningRecordEntry) -from LearningAPI.views.notify import slack_notify from .personality import myers_briggs_persona @@ -241,6 +239,8 @@ def list(self, request): def assess(self, request, pk): """POST when a student starts working on book assessment. PUT to change status.""" + slack = SlackAPI() + if request.method == "PUT": student = NssUser.objects.get(pk=pk) assessment_status = StudentAssessmentStatus.objects.get(pk=request.data['statusId']) @@ -252,18 +252,18 @@ def assess(self, request, pk): try: if latest_assessment.status.status == 'Ready for Review': - slack_notify( + slack.send_message( message="🎉 Congratulations! You've completed your self-assessment. Your coaching team will review your work and provide feedback soon.", channel=student.slack_handle ) - slack_notify( + slack.send_message( message=f'{student.full_name} in {student.current_cohort["name"]} has completed their self-assessment for {latest_assessment.assessment.name}.\n\nReview it at {latest_assessment.url}', channel=student.current_cohort["ic"] ) if latest_assessment.status.status == 'Reviewed and Complete': - slack_notify( + slack.send_message( message=f':fox-yay-woo-hoo: Self-Assessment Review Complete\n\n\n:white_check_mark: Your coaching team just marked {latest_assessment.assessment.name} as completed.\n\nVisit https://learning.nss.team to view your latest messages and statuses.', channel=latest_assessment.student.slack_handle ) @@ -362,14 +362,14 @@ def assess(self, request, pk): # Send message to student created_repo_url = f'https://github.com/{student_org_name}/{repo_name}' - slack_notify( + slack.send_message( f"🐙 Your self-assessment repository has been created. Visit the URL below and clone the project to your machine.\n\n{created_repo_url}", student.slack_handle ) # Send message to instructors slack_channel = student.assigned_cohorts.order_by("-id").first().cohort.slack_channel - slack_notify( + slack.send_message( f"📝 {student.full_name} has started the self-assessment for {assessment.name}.", slack_channel ) diff --git a/LearningAPI/views/team_maker_view.py b/LearningAPI/views/team_maker_view.py new file mode 100644 index 0000000..b22c1e5 --- /dev/null +++ b/LearningAPI/views/team_maker_view.py @@ -0,0 +1,193 @@ +import random, string + +from rest_framework import serializers, status +from rest_framework.viewsets import ViewSet +from rest_framework.response import Response +from rest_framework.decorators import action + +from LearningAPI.models.people import StudentTeam, GroupProjectRepository, NSSUserTeam, Cohort +from LearningAPI.models.coursework import Project +from LearningAPI.utils import GithubRequest, SlackAPI + + +class TeamRepoSerializer(serializers.ModelSerializer): + class Meta: + model = GroupProjectRepository + fields = ( 'id', 'project', 'repository' ) + + +class StudentTeamSerializer(serializers.ModelSerializer): + students = serializers.SerializerMethodField() + repositories = TeamRepoSerializer(many=True) + + def get_students(self, obj): + return [{'id': student.id, 'name': student.name} for student in obj.students.all() if not student.user.is_staff] + + class Meta: + model = StudentTeam + fields = ( 'group_name', 'cohort', 'sprint_team', 'students', 'repositories' ) + + +class TeamMakerView(ViewSet): + """Team Maker""" + def list(self, request): + cohort_id = request.query_params.get('cohort', None) + + try: + cohort = Cohort.objects.get(pk=cohort_id) + teams = StudentTeam.objects.filter(cohort=cohort).order_by('-pk') + + response = StudentTeamSerializer(teams, many=True).data + return Response(response, status=status.HTTP_200_OK) + except Cohort.DoesNotExist as ex: + return Response({'message': str(ex)}, status=status.HTTP_404_NOT_FOUND) + + def create(self, request): + """Handle POST operations + + Returns: + Response -- JSON serialized instance + """ + + cohort_id = request.data.get('cohort', None) + student_list = request.data.get('students', None) + group_project_id = request.data.get('groupProject', None) + team_prefix = request.data.get('weeklyPrefix', None) + team_index = request.data.get('teamIndex', None) + + # Create the student team in the database + cohort = Cohort.objects.get(pk=cohort_id) + team = StudentTeam() + team.group_name = "" + team.cohort = cohort + team.sprint_team = group_project_id is not None + + # Create the Slack channel and add students to it and store the channel ID in the team + # The cohort name will always end in a number. Split on the space and get the last item + slack = SlackAPI() + random_team_suffix = ''.join(random.choice(string.ascii_lowercase) for i in range(6)) + channel_name = f"{team_prefix}-{cohort.name.split(' ')[-1]}-{random_team_suffix}" + team.slack_channel = slack.create_channel(channel_name, student_list) + team.save() + + # Assign the students to the team. Use a for loop with enumerate to get the index of the student + for student in student_list: + student_team = NSSUserTeam() + student_team.student_id = student + student_team.team = team + student_team.save() + + # Create group project repository if group project is not None + if group_project_id is not None: + project = Project.objects.get(pk=group_project_id) + + # Get student Github organization name + student_org_name = cohort.info.student_organization_url.split("/")[-1] + + # Replace all spaces in the assessment name with hyphens + random_suffix = ''.join(random.choice(string.ascii_lowercase) for i in range(6)) + repo_name = f'{project.name.replace(" ", "-")}-client-{random_suffix}' + + # Create the client repository for the group project + gh_request = GithubRequest() + + response = gh_request.create_repository( + source_url=project.client_template_url, + student_org_url=cohort.info.student_organization_url, + repo_name=repo_name, + project_name=project.name + ) + + if response.status_code != 201: + return Response({'message': 'Failed to create repository'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # Grant write permissions to the students + for student in team.students.all(): + gh_request.assign_student_permissions( + student_org_name=student_org_name, + repo_name=repo_name, + student=student + ) + + # Save the team's client-side repository URL to the database + group_project_repo = GroupProjectRepository() + group_project_repo.team_id = team.id + group_project_repo.project = project + group_project_repo.repository = f'https://github.com/{student_org_name}/{repo_name}' + group_project_repo.save() + + + # Send message to project team's Slack channel with the repository URL + created_repo_url = f'https://github.com/{student_org_name}/{repo_name}' + slack.send_message( + text=f"🐙 Your client repository has been created. Visit the URL below and clone the project to your machine.\n\n{created_repo_url}", + channel=team.slack_channel + ) + + + # Create the API repository for the group project if it exists + if project.api_template_url: + api_repo_name = f'{project.name.replace(" ", "-")}-api-{random_suffix}' + + gh_request.create_repository( + source_url=project.api_template_url, + student_org_url=cohort.info.student_organization_url, + repo_name=api_repo_name, + project_name=project.name + ) + + # Grant write permissions to the students + for student in team.students.all(): + gh_request.assign_student_permissions( + student_org_name=student_org_name, + repo_name=api_repo_name, + student=student + ) + + # Save the team's API repository URL to the database + group_project_repo = GroupProjectRepository() + group_project_repo.team_id = team.id + group_project_repo.project = project + group_project_repo.repository = f'https://github.com/{student_org_name}/{api_repo_name}' + group_project_repo.save() + + # Send message to project team's Slack channel with the repository URL + created_repo_url = f'https://github.com/{student_org_name}/{api_repo_name}' + slack.send_message( + text=f"🐙 Your API repository has been created. Visit the URL below and clone the project to your machine.\n\n{created_repo_url}", + channel=team.slack_channel + ) + + serialized_team = StudentTeamSerializer(team, many=False).data + + return Response(serialized_team, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=['delete']) + def reset(self, request): + cohort_id = request.query_params.get('cohort', None) + + if cohort_id is None: + return Response({'message': 'No cohort ID provided'}, status=status.HTTP_400_BAD_REQUEST) + + try: + cohort = Cohort.objects.get(pk=cohort_id) + self._delete_slack_channels(cohort) + self._delete_cohort_teams(cohort) + return Response(None, status=status.HTTP_204_NO_CONTENT) + except Cohort.DoesNotExist as ex: + return Response({'message': str(ex)}, status=status.HTTP_404_NOT_FOUND) + except Exception as ex: + return Response({'message': str(ex)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def _delete_cohort_teams(self, cohort): + NSSUserTeam.objects.filter(team__cohort=cohort).delete() + GroupProjectRepository.objects.filter(team__cohort=cohort).delete() + StudentTeam.objects.filter(cohort=cohort).delete() + + def _delete_slack_channels(self, cohort): + current_teams = StudentTeam.objects.filter(cohort=cohort) + + for team in current_teams: + slack = SlackAPI() + slack.delete_channel(team.slack_channel) + diff --git a/LearningPlatform/urls.py b/LearningPlatform/urls.py index fe48bd8..bb5f06a 100644 --- a/LearningPlatform/urls.py +++ b/LearningPlatform/urls.py @@ -22,33 +22,32 @@ from LearningAPI import views router = routers.DefaultRouter(trailing_slash=False) -router.register(r'profile', views.Profile, 'profile') -router.register(r'personality', views.StudentPersonalityViewSet, 'personality') -router.register(r'records', views.LearningRecordViewSet, 'record') -router.register(r'weights', views.LearningWeightViewSet, 'weight') -router.register(r'opportunities', views.OpportunityViewSet, 'opportunity') -router.register(r'capstones', views.CapstoneViewSet, 'capstone') -router.register(r'cohorts', views.CohortViewSet, 'cohort') -router.register(r'students', views.StudentViewSet, 'student') -router.register(r'courses', views.CourseViewSet, 'course') -router.register(r'books', views.BookViewSet, 'book') -router.register(r'projects', views.ProjectViewSet, 'project') router.register(r'assessments', views.StudentAssessmentView, 'assessment') router.register(r'bookassessments', views.BookAssessmentView, 'bookassessment') -router.register(r'statuses', views.AssessmentStatusView, 'status') -router.register(r'proposalstatuses', views.ProposalStatusView, 'proposalstatus') -router.register(r'timelines', views.TimelineView, 'timeline') -router.register(r'objectives', views.LearningObjectiveViewSet, 'learningobjective') -router.register(r'slackchannels', views.SlackChannel, 'slackchannel') -router.register(r'messages', views.SlackMessage, 'slackmessage') -router.register(r'coreskills', views.CoreSkillViewSet, 'coreskill') +router.register(r'books', views.BookViewSet, 'book') +router.register(r'capstones', views.CapstoneViewSet, 'capstone') +router.register(r'cohortinfo', views.CohortInfoViewSet, 'info') +router.register(r'cohorts', views.CohortViewSet, 'cohort') router.register(r'coreskillrecords', views.CoreSkillRecordViewSet, 'coreskillrecord') -router.register(r'tags', views.TagViewSet, 'tag') -router.register(r'studenttags', views.StudentTagViewSet, 'studenttag') +router.register(r'coreskills', views.CoreSkillViewSet, 'coreskill') +router.register(r'courses', views.CourseViewSet, 'course') router.register(r'notes', views.StudentNoteViewSet, 'note') router.register(r'notetypes', views.StudentNoteTypeViewSet, 'notetypes') +router.register(r'objectives', views.LearningObjectiveViewSet, 'learningobjective') +router.register(r'opportunities', views.OpportunityViewSet, 'opportunity') router.register(r'personalities', views.PersonalityView, 'person') -router.register(r'cohortinfo', views.CohortInfoViewSet, 'info') +router.register(r'profile', views.Profile, 'profile') +router.register(r'projects', views.ProjectViewSet, 'project') +router.register(r'proposalstatuses', views.ProposalStatusView, 'proposalstatus') +router.register(r'personality', views.StudentPersonalityViewSet, 'personality') +router.register(r'records', views.LearningRecordViewSet, 'record') +router.register(r'statuses', views.AssessmentStatusView, 'status') +router.register(r'students', views.StudentViewSet, 'student') +router.register(r'studenttags', views.StudentTagViewSet, 'studenttag') +router.register(r'tags', views.TagViewSet, 'tag') +router.register(r'teams', views.TeamMakerView, 'team_maker') +router.register(r'timelines', views.TimelineView, 'timeline') +router.register(r'weights', views.LearningWeightViewSet, 'weight') urlpatterns = [ diff --git a/learningplatform.dbml b/learningplatform.dbml index 47f0897..eb5cccd 100644 --- a/learningplatform.dbml +++ b/learningplatform.dbml @@ -209,6 +209,27 @@ Table "LearningAPI_nssusercohort" [headercolor: #c0392b] { } } +Table "LearningAPI_studentteam" [headercolor: #e00ead] { + "id" int [pk, not null, increment] + "cohort_id" int4 [not null, ref: < LearningAPI_cohort.id] + "group_name" varchar + "sprint_team" bool [not null, default: False] + "project_id" int4 [ref: < LearningAPI_project.id] +} + +Table "LearningAPI_groupprojectrepository" [headercolor: #e00ead] { + "id" int [pk, not null, increment] + "team_id" int4 [not null, ref: < LearningAPI_team.id] + "repo_url" varchar [not null] +} + +Table "LearningAPI_nssuserteam" [headercolor: #e00ead] { + "id" int [pk, not null, increment] + "team_id" int4 [not null, ref: < LearningAPI_team.id] + "nss_user_id" int4 [not null, ref: < LearningAPI_nssuser.id] +} + + Table "LearningAPI_objectivetag" [headercolor: #3498db] { "id" int [pk, not null, increment] "tag_id" int4 [not null, ref: < LearningAPI_tag.id] diff --git a/learopsdev.session.sql b/learopsdev.session.sql index ae01d26..844d4ad 100644 --- a/learopsdev.session.sql +++ b/learopsdev.session.sql @@ -1,49 +1,22 @@ -- View all tables -select * from pg_catalog.pg_tables; +SELECT * from pg_catalog.pg_tables; -select * from "auth_user" order by id desc; -select * from "socialaccount_socialaccount" order by id desc; -select * from "authtoken_token" where user_id = 470; -select * from "LearningAPI_nssuser" where user_id = 470; -select * from "LearningAPI_nssusercohort" where nss_user_id = 468; -update "LearningAPI_nssusercohort" - set is_github_org_member = FALSE - where nss_user_id = 468; +SELECT * from "auth_user" order by id desc; +SELECT * from "socialaccount_socialaccount" order by id desc; +SELECT * from "authtoken_token" where user_id = 470; +SELECT * from "LearningAPI_nssuser" where user_id = 470; +SELECT * from "LearningAPI_nssusercohort" where nss_user_id = 468; +SELECT * from "LearningAPI_studentteam"; +SELECT * FROM "LearningAPI_cohort"; - --- Drop all tables -DO $$ DECLARE - r RECORD; -BEGIN - FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = current_schema()) LOOP - EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE'; - END LOOP; -END $$; -DROP FUNCTION IF EXISTS get_cohort_student_data(INT); DROP FUNCTION IF EXISTS get_student_details(INT); DROP FUNCTION IF EXISTS get_project_average_start_delay(INT); -ALTER TABLE auth_user ALTER COLUMN last_login DROP NOT NULL; - - - - - -select * FROM "LearningAPI_nssuser"; -select * FROM "LearningAPI_cohort"; - - - - - - - - DROP FUNCTION IF EXISTS get_cohort_student_data(INT); -select * from get_cohort_student_data(29); +SELECT * from get_cohort_student_data(29); @@ -368,7 +341,7 @@ SELECT * FROM "LearningAPI_learningrecordentry" lr order by id desc; -select +SELECT sum(lw.weight) as score, au."first_name" || ' ' || au."last_name" AS student_name from "LearningAPI_learningrecord" lr @@ -385,4 +358,4 @@ group by student_name -select * from "LearningAPI_studentnote" order by id desc; \ No newline at end of file +SELECT * from "LearningAPI_studentnote" order by id desc; \ No newline at end of file