diff --git a/backend/api/management/commands/notification_setup.py b/backend/api/management/commands/notification_setup.py new file mode 100644 index 00000000..aa22a743 --- /dev/null +++ b/backend/api/management/commands/notification_setup.py @@ -0,0 +1,22 @@ +from django.contrib.auth.models import User +from django.core.management.base import BaseCommand +from api import models + + +class Command(BaseCommand): + help = 'Set / Update the initial values of the notification details for every user.' + + def handle(self, *args, **options): + # Add the tiers + for user in User.objects.all(): + try: + for key in models.Settings.NOTIFICATIONS: + if key not in user.settings.notifications: + user.settings.notifications[key] = True + user.settings.save() + except models.Settings.DoesNotExist: + models.Settings.objects.create( + user=user, + theme='beta', + notifications={key: True for key in models.Settings.NOTIFICATIONS}, + ) diff --git a/backend/api/migrations/0015_settings_updates.py b/backend/api/migrations/0015_settings_updates.py new file mode 100644 index 00000000..2832d6d1 --- /dev/null +++ b/backend/api/migrations/0015_settings_updates.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.11 on 2022-01-27 13:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0014_user_settings'), + ] + + operations = [ + migrations.AddField( + model_name='settings', + name='notifications', + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name='settings', + name='theme', + field=models.CharField(max_length=24), + ), + ] diff --git a/backend/api/migrations/0016_notifications.py b/backend/api/migrations/0016_notifications.py new file mode 100644 index 00000000..40a2166f --- /dev/null +++ b/backend/api/migrations/0016_notifications.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.11 on 2022-01-28 10:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0015_settings_updates'), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('link', models.TextField()), + ('read', models.BooleanField(default=False)), + ('text', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('type', models.TextField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + ] diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py index 7d719f94..cc378df7 100644 --- a/backend/api/models/__init__.py +++ b/backend/api/models/__init__.py @@ -3,6 +3,7 @@ from .gear import Gear from .job import Job from .loot import Loot +from .notification import Notification from .settings import Settings from .team import Team from .team_member import TeamMember @@ -19,6 +20,8 @@ 'Loot', + 'Notification', + 'Settings', 'Team', diff --git a/backend/api/models/notification.py b/backend/api/models/notification.py new file mode 100644 index 00000000..6c8bc432 --- /dev/null +++ b/backend/api/models/notification.py @@ -0,0 +1,17 @@ +from django.contrib.auth.models import User +from django.db import models + + +class Notification(models.Model): + link = models.TextField() + read = models.BooleanField(default=False) + text = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + type = models.TextField() # Type field maintains the internal notification type, used to send updates via ws + user = models.ForeignKey(User, on_delete=models.CASCADE) + + def __str__(self): + return f'Notification #{self.id} for {self.user.username}' + + class Meta: + ordering = ['-timestamp'] diff --git a/backend/api/models/settings.py b/backend/api/models/settings.py index ec485086..ecb8dd1c 100644 --- a/backend/api/models/settings.py +++ b/backend/api/models/settings.py @@ -9,18 +9,14 @@ class Settings(models.Model): - BETA = 'beta' - BLUE = 'blue' - GREEN = 'green' - PURPLE = 'purple' - RED = 'red' - THEMES = ( - (BETA, BETA), - (BLUE, BLUE), - (GREEN, GREEN), - (PURPLE, PURPLE), - (RED, RED), - ) + NOTIFICATIONS = { + 'loot_tracker_update', + 'team_join', + 'team_lead', + 'verify_fail', + 'verify_success', + } - theme = models.CharField(max_length=24, choices=THEMES) + notifications = models.JSONField(default=dict) + theme = models.CharField(max_length=24) user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='settings') diff --git a/backend/api/models/team_member.py b/backend/api/models/team_member.py index 5e3427ce..e6020f15 100644 --- a/backend/api/models/team_member.py +++ b/backend/api/models/team_member.py @@ -9,7 +9,7 @@ class TeamMember(models.Model): # TODO - Gotta warn about leaving teams or deleting BISLists (which can't be done yet) bis_list = models.ForeignKey('BISList', on_delete=models.PROTECT) character = models.ForeignKey('Character', on_delete=models.CASCADE) - # TODO - Can't delete if you're the raid lead / move raidlead to someone else if character is deleted + # TODO - Can't delete if you're the team lead / move teamlead to someone else if character is deleted lead = models.BooleanField(default=False) team = models.ForeignKey('Team', on_delete=models.CASCADE, related_name='members') diff --git a/backend/api/notifier.py b/backend/api/notifier.py new file mode 100644 index 00000000..31efeb4d --- /dev/null +++ b/backend/api/notifier.py @@ -0,0 +1,63 @@ +""" +Notifier contains a series of functions to create notifications for every different +type used in the system, without having to add messy code elsewhere + +Also will handle sending info to websockets when we get there +""" +from django.contrib.auth.models import User +from . import models + + +def _create_notif(user: User, text: str, link: str, type: str): + """ + Actually does the work of creating a Notification (and sending it down the websockets later) + Also is where the notification settings are checked, we won't save notifications that the User doesn't want + """ + # First we ensure that the User is set up to receive the notification type + try: + send = user.settings.notifications[type] + except (AttributeError, models.Settings.DoesNotExist, KeyError): + send = True + + if not send: + return + + # If we make it to this point, create the object and then push updates down the web socket + models.Notification.objects.create(user=user, text=text, link=link, type=type) + # TODO - Websocket stuff + + +def loot_tracker_update(bis: models.BISList, team: models.Team): + char = bis.owner + text = f'"{char}"\'s {bis.job.id} BIS List was updated via "{team.name}"\'s Loot Tracker!' + link = f'/characters/{char.id}/bis_list/{bis.id}/' + user = char.user + _create_notif(user, text, link, 'loot_tracker_update') + + +def team_join(char: models.Character, team: models.Team): + text = f'{char} has joined {team.name}!' + link = f'/team/{team.id}/' + user = team.members.get(lead=True).character.user + _create_notif(user, text, link, 'team_join') + + +def team_lead(char: models.Character, team: models.Team): + text = f'{char} has been made the Team Leader of {team.name}!' + link = f'/team/{team.id}/' + user = char.user + _create_notif(user, text, link, 'team_lead') + + +def verify_fail(char: models.Character, error: str): + text = f'The verification of {char} has failed! Reason: {error}' + link = f'/characters/{char.id}/' + user = char.user + _create_notif(user, text, link, 'verify_fail') + + +def verify_success(char: models.Character): + text = f'The verification of {char} has succeeded!' + link = f'/characters/{char.id}/' + user = char.user + _create_notif(user, text, link, 'verify_success') diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py index 5e6aa4dc..0c12d5db 100644 --- a/backend/api/serializers/__init__.py +++ b/backend/api/serializers/__init__.py @@ -3,6 +3,7 @@ from .gear import GearSerializer from .job import JobSerializer from .loot import LootSerializer, LootCreateSerializer, LootCreateWithBISSerializer +from .notification import NotificationSerializer from .settings import SettingsSerializer from .team import ( TeamSerializer, @@ -28,6 +29,8 @@ 'LootCreateSerializer', 'LootCreateWithBISSerializer', + 'NotificationSerializer', + 'SettingsSerializer', 'TeamSerializer', diff --git a/backend/api/serializers/notification.py b/backend/api/serializers/notification.py new file mode 100644 index 00000000..7379b69d --- /dev/null +++ b/backend/api/serializers/notification.py @@ -0,0 +1,17 @@ +""" +Serializer for Notification entries +""" +# lib +from rest_framework import serializers +# local +from api.models import Notification + +__all__ = [ + 'NotificationSerializer', +] + + +class NotificationSerializer(serializers.ModelSerializer): + class Meta: + exclude = ['user'] + model = Notification diff --git a/backend/api/serializers/settings.py b/backend/api/serializers/settings.py index 5f9b333e..eded936b 100644 --- a/backend/api/serializers/settings.py +++ b/backend/api/serializers/settings.py @@ -1,6 +1,8 @@ """ Serializer for a request user's information """ +# stdlib +from typing import Dict # lib from rest_framework import serializers # local @@ -10,9 +12,40 @@ 'SettingsSerializer', ] +NOTIFICATION_VALUES = {True, False} + +THEMES = { + 'beta', + 'blue', + 'green', + 'purple', + 'red', + 'trans', +} + class SettingsSerializer(serializers.ModelSerializer): class Meta: model = Settings - fields = ['theme'] + fields = ['notifications', 'theme'] + + def validate_notifications(self, notifications: Dict[str, bool]) -> Dict[str, bool]: + """ + Ensure that the notifications dict sent by the user only contains valid keys + """ + for key, value in notifications.items(): + # Check that the key is in the allowed strings, and the value is a valid bool + if key not in Settings.NOTIFICATIONS: + raise serializers.ValidationError(f'"{key}" is not a valid choice.') + if value not in NOTIFICATION_VALUES: + raise serializers.ValidationError(f'"{key}" does not have a boolean for a value.') + return notifications + + def validate_theme(self, theme: str) -> str: + """ + Ensure the theme is in the set of allowed themes + """ + if theme not in THEMES: + raise serializers.ValidationError(f'"{theme}" is not a valid choice.') + return theme diff --git a/backend/api/serializers/team.py b/backend/api/serializers/team.py index 146d3fc4..7a06d2b0 100644 --- a/backend/api/serializers/team.py +++ b/backend/api/serializers/team.py @@ -25,13 +25,13 @@ class Meta: class TeamUpdateSerializer(serializers.ModelSerializer): - raid_lead = serializers.IntegerField() + team_lead = serializers.IntegerField() tier_id = serializers.IntegerField() class Meta: model = Team - fields = ['name', 'tier_id', 'raid_lead'] - write_only_fields = ['raid_lead'] + fields = ['name', 'tier_id', 'team_lead'] + write_only_fields = ['team_lead'] def validate_tier_id(self, tier_id: int) -> int: """ @@ -45,16 +45,16 @@ def validate_tier_id(self, tier_id: int) -> int: return tier_id - def validate_raid_lead(self, raid_lead_id: int) -> int: + def validate_team_lead(self, team_lead_id: int) -> int: """ - Ensure that the raid lead id is a valid int and refers to a valid member of the team. - The sent raid lead id will be the id of the character, the returned value the id of the TM object. + Ensure that the team lead id is a valid int and refers to a valid member of the team. + The sent team lead id will be the id of the character, the returned value the id of the TM object. """ # Ensure it corresponds with a member of this team try: - member = self.instance.members.get(character__pk=raid_lead_id) + member = self.instance.members.get(character__pk=team_lead_id) except TeamMember.DoesNotExist: - raise serializers.ValidationError('Please select a member of the Team to be the new raid lead.') + raise serializers.ValidationError('Please select a member of the Team to be the new team lead.') return member.id diff --git a/backend/api/serializers/user.py b/backend/api/serializers/user.py index 42755ef1..5da861f3 100644 --- a/backend/api/serializers/user.py +++ b/backend/api/serializers/user.py @@ -1,6 +1,8 @@ """ Serializer for a request user's information """ +# stdlib +from typing import Dict # lib from rest_framework import serializers # local @@ -14,6 +16,7 @@ class UserSerializer(serializers.Serializer): avatar_url = serializers.SerializerMethodField() id = serializers.IntegerField() + notifications = serializers.SerializerMethodField() theme = serializers.SerializerMethodField() username = serializers.CharField() @@ -25,6 +28,20 @@ def get_avatar_url(self, obj) -> str: return obj.socialaccount_set.first().get_avatar_url() return '' + def get_notifications(self, obj) -> Dict[str, bool]: + """ + Populate a full dictionary of notifications, filling defaults in as needed + """ + defaults = {key: True for key in Settings.NOTIFICATIONS} + + try: + defaults.update(obj.settings.notifications) + except (AttributeError, Settings.DoesNotExist): + # Don't have to do anything here + pass + + return defaults + def get_theme(self, obj) -> str: """ Get the theme the user has set. Defaults to beta if there's no settings instance diff --git a/backend/api/tasks.py b/backend/api/tasks.py index 8b9fe676..0fc4fde4 100644 --- a/backend/api/tasks.py +++ b/backend/api/tasks.py @@ -5,6 +5,7 @@ """ # stdlib from datetime import timedelta +from typing import Optional # lib import requests from asgiref.sync import async_to_sync @@ -13,6 +14,7 @@ from celery.utils.log import get_task_logger from django.utils import timezone # local +from . import notifier from .models import Character logger = get_task_logger(__name__) @@ -22,7 +24,7 @@ ) -async def xivapi_lookup(pk: str, token: str, log) -> bool: +async def xivapi_lookup(pk: str, token: str, log) -> Optional[str]: """ Actually check XIVAPI for the specified token being present in the specified character's bio """ @@ -31,8 +33,7 @@ async def xivapi_lookup(pk: str, token: str, log) -> bool: response = requests.get(url, headers={'User-Agent': USER_AGENT}) if response.status_code != 200: log.error(f'Received {response.status_code} response from Lodestone. Cannot verify right now.') - # TODO - give some notifications back to the user - return False + return 'Lodestone may be down.' soup = BeautifulSoup(response.content, 'html.parser') @@ -42,13 +43,13 @@ async def xivapi_lookup(pk: str, token: str, log) -> bool: # Attempt the selfintroduction one first for el in soup.find_all('div', class_='character__selfintroduction'): if token in el.getText(): - return True + return None for el in soup.find_all('div', class_='character__character_profile'): if token in el.getText(): - return True + return None - return False + return 'Could not find the verification code in the Lodestone profile.' @shared_task(name='verify_character') @@ -68,10 +69,11 @@ def verify_character(pk: int): # Call the xivapi function in a sync context logger.debug('calling async function') - valid = async_to_sync(xivapi_lookup)(obj.lodestone_id, obj.token, logger) + err = async_to_sync(xivapi_lookup)(obj.lodestone_id, obj.token, logger) logger.debug('finished async function') - if not valid: + if err is not None: + notifier.verify_fail(obj, err) logger.info(f'Character #{pk} could not be verified. Exiting.') return @@ -86,6 +88,7 @@ def verify_character(pk: int): logger.debug(f'Found {objs.count()} instances of Character #{obj.lodestone_id} to delete.') objs.delete() # Then we're done! + notifier.verify_success(obj) @shared_task(name='cleanup') diff --git a/backend/api/tests/test_character.py b/backend/api/tests/test_character.py index 57f43fc8..9a7ff28a 100644 --- a/backend/api/tests/test_character.py +++ b/backend/api/tests/test_character.py @@ -6,7 +6,8 @@ from django.urls import reverse from rest_framework import status # local -from api.models import BISList, Character, Gear, Job +from api import notifier +from api.models import BISList, Character, Gear, Notification, Job, Settings from api.serializers import CharacterCollectionSerializer, CharacterDetailsSerializer from .test_base import SavageAimTestCase @@ -16,12 +17,15 @@ def _fake_task(pk: int): Handle what celery would handle if it were running """ try: - obj = Character.objects.get(pk=pk, verified=False) + obj = Character.objects.get(pk=pk) except Character.DoesNotExist: return - - obj.verified = True - obj.save() + if not obj.verified: + obj.verified = True + obj.save() + notifier.verify_success(obj) + else: + notifier.verify_fail(obj, 'Already Verified!') class CharacterCollection(SavageAimTestCase): @@ -267,6 +271,7 @@ def tearDown(self): """ Clean up the DB after each test """ + Notification.objects.all().delete() Character.objects.all().delete() @patch('api.views.character.verify_character.delay', side_effect=_fake_task) @@ -297,6 +302,43 @@ def test_verify(self, mocked_task): # Do some testing of the mocked task information mocked_task.assert_called() + # Check Notifications + self.assertEqual(Notification.objects.filter(user=user).count(), 1) + notif = Notification.objects.filter(user=user).first() + self.assertEqual(notif.link, f'/characters/{char.id}/') + self.assertEqual(notif.text, f'The verification of {char} has succeeded!') + self.assertEqual(notif.type, 'verify_success') + self.assertFalse(notif.read) + + def test_verify_fail_notifs(self): + """ + Just call the mock task with a verified character and test the notifier task works + Also test after changing the notif settings to ensure a second notif isn't sent + """ + user = self._get_user() + char = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=user, + name='Char 1', + world='Lich', + verified=True, + ) + _fake_task(char.id) + + # Check Notification was created properly + self.assertEqual(Notification.objects.filter(user=user).count(), 1) + notif = Notification.objects.filter(user=user).first() + self.assertEqual(notif.link, f'/characters/{char.id}/') + self.assertEqual(notif.text, f'The verification of {char} has failed! Reason: Already Verified!') + self.assertEqual(notif.type, 'verify_fail') + self.assertFalse(notif.read) + + # Update settings and try again + Settings.objects.create(user=user, theme='beta', notifications={'verify_fail': False}) + _fake_task(char.id) + self.assertEqual(Notification.objects.filter(user=user).count(), 1) + @patch('api.views.character.verify_character.delay', side_effect=_fake_task) def test_404(self, mocked_task): """ diff --git a/backend/api/tests/test_loot.py b/backend/api/tests/test_loot.py index 79bb4ef3..3d1c4b36 100644 --- a/backend/api/tests/test_loot.py +++ b/backend/api/tests/test_loot.py @@ -3,7 +3,7 @@ from django.core.management import call_command from django.urls import reverse from rest_framework import status -from api.models import BISList, Character, Gear, Loot, Team, TeamMember, Tier +from api.models import BISList, Character, Gear, Loot, Notification, Team, TeamMember, Tier from .test_base import SavageAimTestCase @@ -29,11 +29,11 @@ def setUp(self): ) # Create two characters belonging to separate users - self.raid_lead = Character.objects.create( + self.team_lead = Character.objects.create( avatar_url='https://img.savageaim.com/abcde', lodestone_id=1234567890, user=self._get_user(), - name='Raid Lead', + name='Team Lead', verified=True, world='Lich', ) @@ -51,7 +51,7 @@ def setUp(self): self.raid_gear = Gear.objects.get(item_level=600, has_weapon=False) self.tome_gear = Gear.objects.get(item_level=600, has_weapon=True) self.crafted = Gear.objects.get(name='Classical') - self.rl_main_bis = BISList.objects.create( + self.tl_main_bis = BISList.objects.create( bis_body=self.raid_gear, bis_bracelet=self.raid_gear, bis_earrings=self.raid_gear, @@ -77,7 +77,7 @@ def setUp(self): current_offhand=self.crafted, current_right_ring=self.crafted, job_id='SGE', - owner=self.raid_lead, + owner=self.team_lead, ) self.mt_alt_bis = BISList.objects.create( bis_body=self.raid_gear, @@ -107,7 +107,7 @@ def setUp(self): job_id='WHM', owner=self.main_tank, ) - self.rl_alt_bis = BISList.objects.create( + self.tl_alt_bis = BISList.objects.create( bis_body=self.tome_gear, bis_bracelet=self.tome_gear, bis_earrings=self.tome_gear, @@ -133,9 +133,9 @@ def setUp(self): current_offhand=self.crafted, current_right_ring=self.crafted, job_id='PLD', - owner=self.raid_lead, + owner=self.team_lead, ) - self.rl_alt_bis2 = BISList.objects.create( + self.tl_alt_bis2 = BISList.objects.create( bis_body=self.tome_gear, bis_bracelet=self.tome_gear, bis_earrings=self.tome_gear, @@ -161,7 +161,7 @@ def setUp(self): current_offhand=self.crafted, current_right_ring=self.crafted, job_id='RPR', - owner=self.raid_lead, + owner=self.team_lead, ) self.mt_main_bis = BISList.objects.create( bis_body=self.tome_gear, @@ -221,7 +221,7 @@ def setUp(self): ) # Lastly, link the characters to the team - self.rl_tm = self.team.members.create(character=self.raid_lead, bis_list=self.rl_main_bis, lead=True) + self.tl_tm = self.team.members.create(character=self.team_lead, bis_list=self.tl_main_bis, lead=True) self.mt_tm = self.team.members.create(character=self.main_tank, bis_list=self.mt_main_bis) # Set up expected response (store it here to avoid redefining it) @@ -237,8 +237,8 @@ def setUp(self): 'job_role': 'tank', }, { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'sage', @@ -267,18 +267,18 @@ def setUp(self): ], }, { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'greed_lists': [ { - 'bis_list_id': self.rl_alt_bis.id, + 'bis_list_id': self.tl_alt_bis.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'paladin', 'job_role': 'tank', }, { - 'bis_list_id': self.rl_alt_bis2.id, + 'bis_list_id': self.tl_alt_bis2.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'reaper', @@ -301,11 +301,11 @@ def setUp(self): ], 'greed': [ { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'greed_lists': [ { - 'bis_list_id': self.rl_alt_bis.id, + 'bis_list_id': self.tl_alt_bis.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'paladin', @@ -341,18 +341,18 @@ def setUp(self): ], }, { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'greed_lists': [ { - 'bis_list_id': self.rl_alt_bis.id, + 'bis_list_id': self.tl_alt_bis.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'paladin', 'job_role': 'tank', }, { - 'bis_list_id': self.rl_alt_bis2.id, + 'bis_list_id': self.tl_alt_bis2.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'reaper', @@ -365,8 +365,8 @@ def setUp(self): 'body': { 'need': [ { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'sage', @@ -415,18 +415,18 @@ def setUp(self): ], }, { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'greed_lists': [ { - 'bis_list_id': self.rl_alt_bis.id, + 'bis_list_id': self.tl_alt_bis.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'paladin', 'job_role': 'tank', }, { - 'bis_list_id': self.rl_alt_bis2.id, + 'bis_list_id': self.tl_alt_bis2.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'reaper', @@ -462,18 +462,18 @@ def setUp(self): ], }, { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'greed_lists': [ { - 'bis_list_id': self.rl_alt_bis.id, + 'bis_list_id': self.tl_alt_bis.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'paladin', 'job_role': 'tank', }, { - 'bis_list_id': self.rl_alt_bis2.id, + 'bis_list_id': self.tl_alt_bis2.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'reaper', @@ -486,8 +486,8 @@ def setUp(self): 'feet': { 'need': [ { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'sage', @@ -513,8 +513,8 @@ def setUp(self): 'earrings': { 'need': [ { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'sage', @@ -563,18 +563,18 @@ def setUp(self): ], }, { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'greed_lists': [ { - 'bis_list_id': self.rl_alt_bis.id, + 'bis_list_id': self.tl_alt_bis.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'paladin', 'job_role': 'tank', }, { - 'bis_list_id': self.rl_alt_bis2.id, + 'bis_list_id': self.tl_alt_bis2.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'reaper', @@ -587,8 +587,8 @@ def setUp(self): 'bracelet': { 'need': [ { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'sage', @@ -622,8 +622,8 @@ def setUp(self): 'job_role': 'tank', }, { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'sage', @@ -652,18 +652,18 @@ def setUp(self): ], }, { - 'member_id': self.rl_tm.pk, - 'character_name': f'{self.raid_lead.name} @ {self.raid_lead.world}', + 'member_id': self.tl_tm.pk, + 'character_name': f'{self.team_lead.name} @ {self.team_lead.world}', 'greed_lists': [ { - 'bis_list_id': self.rl_alt_bis.id, + 'bis_list_id': self.tl_alt_bis.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'paladin', 'job_role': 'tank', }, { - 'bis_list_id': self.rl_alt_bis2.id, + 'bis_list_id': self.tl_alt_bis2.id, 'current_gear_name': self.crafted.name, 'current_gear_il': self.crafted.item_level, 'job_icon_name': 'reaper', @@ -679,6 +679,7 @@ def tearDown(self): """ Clean up the DB after each test """ + Notification.objects.all().delete() Loot.objects.all().delete() TeamMember.objects.all().delete() Team.objects.all().delete() @@ -702,28 +703,28 @@ def test_calculator(self): self.assertEqual(content[item], self.expected_gear[item], item) # Update some of the BIS Lists, remove the equivalent from the local response and check again - self.rl_main_bis.current_mainhand = self.raid_weapon - self.rl_main_bis.current_feet = self.raid_gear - self.rl_main_bis.current_right_ring = self.raid_gear - self.rl_main_bis.save() + self.tl_main_bis.current_mainhand = self.raid_weapon + self.tl_main_bis.current_feet = self.raid_gear + self.tl_main_bis.current_right_ring = self.raid_gear + self.tl_main_bis.save() self.expected_gear['mainhand']['need'].pop(1) self.expected_gear['feet']['need'].pop(0) self.expected_gear['ring']['need'].pop(1) - self.rl_alt_bis.current_offhand = self.raid_weapon - self.rl_alt_bis.current_legs = self.raid_gear - self.rl_alt_bis.current_left_ring = self.raid_gear - self.rl_alt_bis.save() + self.tl_alt_bis.current_offhand = self.raid_weapon + self.tl_alt_bis.current_legs = self.raid_gear + self.tl_alt_bis.current_left_ring = self.raid_gear + self.tl_alt_bis.save() self.expected_gear['offhand']['greed'].pop(0) self.expected_gear['legs']['greed'][1]['greed_lists'].pop(0) self.expected_gear['ring']['greed'][1]['greed_lists'].pop(0) - self.rl_alt_bis2.current_mainhand = self.raid_weapon - self.rl_alt_bis2.current_head = self.raid_gear - self.rl_alt_bis2.current_necklace = self.raid_gear - self.rl_alt_bis2.save() + self.tl_alt_bis2.current_mainhand = self.raid_weapon + self.tl_alt_bis2.current_head = self.raid_gear + self.tl_alt_bis2.current_necklace = self.raid_gear + self.tl_alt_bis2.save() self.expected_gear['mainhand']['greed'][1]['greed_lists'].pop(1) self.expected_gear['head']['greed'][1]['greed_lists'].pop(1) @@ -773,7 +774,7 @@ def test_history(self): l3 = Loot.objects.create( greed=False, item='mount', - member=self.rl_tm, + member=self.tl_tm, team=self.team, obtained=datetime.today(), tier=self.team.tier, @@ -789,7 +790,7 @@ def test_history(self): l1 = Loot.objects.create( greed=True, item='mainhand', - member=self.rl_tm, + member=self.tl_tm, team=self.team, obtained=datetime.today(), tier=self.team.tier, @@ -815,7 +816,7 @@ def test_history(self): { 'greed': True, 'item': 'Mainhand', - 'member': 'Raid Lead @ Lich', + 'member': 'Team Lead @ Lich', 'obtained': l1.obtained.strftime('%Y-%m-%d'), 'id': l1.pk, }, @@ -829,7 +830,7 @@ def test_history(self): { 'greed': False, 'item': 'Mount', - 'member': 'Raid Lead @ Lich', + 'member': 'Team Lead @ Lich', 'obtained': l1.obtained.strftime('%Y-%m-%d'), 'id': l3.pk, }, @@ -867,7 +868,7 @@ def test_create(self): data = { 'greed': False, - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, 'item': 'mount', 'obtained': obtained, } @@ -890,7 +891,7 @@ def test_create(self): self.assertEqual(greed.member, self.mt_tm) self.assertEqual(greed.item, 'tome-armour-augment') self.assertFalse(need.greed) - self.assertEqual(need.member, self.rl_tm) + self.assertEqual(need.member, self.tl_tm) self.assertEqual(need.item, 'mount') def test_create_400(self): @@ -954,7 +955,7 @@ def test_create_with_bis(self): # We don't have to check initial values only post values need_data_ring = { 'greed': False, - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, 'item': 'ring', 'greed_bis_id': None, } @@ -965,7 +966,7 @@ def test_create_with_bis(self): } need_data_body = { 'greed': False, - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, 'item': 'body', } greed_data_ring = { @@ -976,9 +977,9 @@ def test_create_with_bis(self): } greed_data_shield = { 'greed': True, - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, 'item': 'offhand', - 'greed_bis_id': self.rl_alt_bis.pk, + 'greed_bis_id': self.tl_alt_bis.pk, } greed_data_body = { 'greed': True, @@ -1012,18 +1013,48 @@ def test_create_with_bis(self): self.assertEqual(content[item], self.expected_gear[item], item) # Check the objects themselves - self.rl_main_bis.refresh_from_db() - self.assertEqual(self.rl_main_bis.bis_right_ring_id, self.raid_gear.pk) - self.assertEqual(self.rl_main_bis.bis_body_id, self.raid_gear.pk) + self.tl_main_bis.refresh_from_db() + self.assertEqual(self.tl_main_bis.bis_right_ring_id, self.raid_gear.pk) + self.assertEqual(self.tl_main_bis.bis_body_id, self.raid_gear.pk) self.mt_main_bis.refresh_from_db() self.assertEqual(self.mt_main_bis.bis_offhand_id, self.raid_weapon.pk) self.mt_alt_bis2.refresh_from_db() self.assertEqual(self.mt_alt_bis2.bis_left_ring_id, self.raid_gear.pk) - self.rl_alt_bis.refresh_from_db() - self.assertEqual(self.rl_alt_bis.bis_offhand_id, self.raid_weapon.pk) + self.tl_alt_bis.refresh_from_db() + self.assertEqual(self.tl_alt_bis.bis_offhand_id, self.raid_weapon.pk) self.mt_alt_bis.refresh_from_db() self.assertEqual(self.mt_alt_bis.bis_body_id, self.raid_gear.pk) + def test_create_with_bis_notification(self): + """ + Do the same as above, but only once, and check the Notification status + """ + write_url = reverse('api:loot_with_bis', kwargs={'team_id': self.team.pk}) + user = self._get_user() + self.client.force_authenticate(user) + + # We don't have to check initial values only post values + data = { + 'greed': False, + 'member_id': self.tl_tm.pk, + 'item': 'ring', + 'greed_bis_id': None, + } + + self.assertEqual(self.client.post(write_url, data).status_code, status.HTTP_201_CREATED) + + # The only thing we need to check is the status of Notifications + self.assertEqual(Notification.objects.filter(user=self.team_lead.user).count(), 1) + notif = Notification.objects.filter(user=self.team_lead.user).first() + self.assertEqual(notif.link, f'/characters/{self.team_lead.id}/bis_list/{self.tl_main_bis.id}/') + self.assertEqual( + notif.text, + f'"{self.team_lead}"\'s {self.tl_main_bis.job.id} BIS List was updated via ' + f'"{self.team.name}"\'s Loot Tracker!', + ) + self.assertEqual(notif.type, 'loot_tracker_update') + self.assertFalse(notif.read) + def test_create_with_bis_400(self): """ Test invalid creation cases for with bis loot api and ensure appropriate errors are returned @@ -1087,7 +1118,7 @@ def test_create_with_bis_400(self): 'greed': True, 'greed_bis_id': None, 'item': 'offhand', - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, } response = self.client.post(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1098,7 +1129,7 @@ def test_create_with_bis_400(self): 'greed': True, 'greed_bis_id': self.mt_alt_bis.pk, 'item': 'offhand', - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, } response = self.client.post(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1107,9 +1138,9 @@ def test_create_with_bis_400(self): data = { 'greed': True, - 'greed_bis_id': self.rl_main_bis.pk, + 'greed_bis_id': self.tl_main_bis.pk, 'item': 'offhand', - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, } response = self.client.post(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1121,24 +1152,24 @@ def test_create_with_bis_400(self): data = { 'greed': True, - 'greed_bis_id': self.rl_alt_bis2.pk, + 'greed_bis_id': self.tl_alt_bis2.pk, 'item': 'offhand', - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, } response = self.client.post(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) content = response.json() self.assertEqual(content['item'], ['Offhand items can only be obtained by a PLD.']) - self.rl_alt_bis2.bis_body = self.tome_gear - self.rl_alt_bis2.bis_left_ring = self.tome_gear - self.rl_alt_bis2.save() + self.tl_alt_bis2.bis_body = self.tome_gear + self.tl_alt_bis2.bis_left_ring = self.tome_gear + self.tl_alt_bis2.save() data = { 'greed': True, - 'greed_bis_id': self.rl_alt_bis2.pk, + 'greed_bis_id': self.tl_alt_bis2.pk, 'item': 'ring', - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, } response = self.client.post(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1150,9 +1181,9 @@ def test_create_with_bis_400(self): data = { 'greed': True, - 'greed_bis_id': self.rl_alt_bis2.pk, + 'greed_bis_id': self.tl_alt_bis2.pk, 'item': 'body', - 'member_id': self.rl_tm.pk, + 'member_id': self.tl_tm.pk, } response = self.client.post(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) @@ -1168,7 +1199,7 @@ def test_404(self): - Invalid ID - Not having a character in the team - - POST when not the raid lead + - POST when not the team lead """ user = self._get_user() self.client.force_authenticate(user) @@ -1180,14 +1211,14 @@ def test_404(self): self.assertEqual(self.client.post(f'{url}bis/').status_code, status.HTTP_404_NOT_FOUND) # Not having a character in the team - self.raid_lead.user = self._create_user() - self.raid_lead.save() + self.team_lead.user = self._create_user() + self.team_lead.save() url = reverse('api:loot_collection', kwargs={'team_id': self.team.pk}) self.assertEqual(self.client.get(url).status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(self.client.post(url).status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(self.client.post(f'{url}bis/').status_code, status.HTTP_404_NOT_FOUND) - # POST while not raid lead + # POST while not team lead self.client.force_authenticate(self.main_tank.user) self.assertEqual(self.client.post(url).status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(self.client.post(f'{url}bis/').status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/api/tests/test_management.py b/backend/api/tests/test_management.py index 5997ecb4..b2972d49 100644 --- a/backend/api/tests/test_management.py +++ b/backend/api/tests/test_management.py @@ -9,6 +9,18 @@ class ManagementCommandTestSuite(SavageAimTestCase): + def test_gear_seed(self): + """ + Run the gear seed command and check that it works as intended + + May need to change numbers as the command gets more gear + """ + call_command('gear_seed', stdout=StringIO()) + + self.assertTrue(models.Gear.objects.exists()) + self.assertEqual(models.Gear.objects.filter(item_level=560).count(), 2) + self.assertEqual(models.Gear.objects.filter(item_level=580).count(), 4) + def test_job_seed(self): """ Run the job seed command and ensure everything is as it should be @@ -43,30 +55,6 @@ def test_job_seed(self): for i in range(len(order)): self.assertEqual(data[i].id, order[i]) - def test_gear_seed(self): - """ - Run the gear seed command and check that it works as intended - - May need to change numbers as the command gets more gear - """ - call_command('gear_seed', stdout=StringIO()) - - self.assertTrue(models.Gear.objects.exists()) - self.assertEqual(models.Gear.objects.filter(item_level=560).count(), 2) - self.assertEqual(models.Gear.objects.filter(item_level=580).count(), 4) - - def test_tier_seed(self): - """ - Run the tier seed command and check that it works as intended - - May need to change numbers as the command gets more tiers - """ - call_command('tier_seed', stdout=StringIO()) - - self.assertTrue(models.Tier.objects.exists()) - self.assertEqual(models.Tier.objects.count(), 1) - self.assertEqual(models.Tier.objects.first().max_item_level, 605) # Asphodelos - def test_loot_team_link(self): """ Create a Loot object with team=None, run the command and ensure the correct team was created @@ -87,7 +75,7 @@ def test_loot_team_link(self): avatar_url='https://img.savageaim.com/abcde', lodestone_id=1234567890, user=self._get_user(), - name='Raid Lead', + name='Team Lead', verified=True, world='Lich', ) @@ -143,3 +131,28 @@ def test_loot_team_link(self): call_command('loot_team_link', stdout=StringIO()) loot.refresh_from_db() self.assertEqual(loot.team, team) + + def test_notification_setup(self): + """ + Set up a User, run the command and ensure that the keys are present and set to True + Add a key with False before running the command to ensure that hasn't been affected + """ + user = self._create_user() + settings = models.Settings.objects.create(user=user, notifications={'verify_fail': False}) + call_command('notification_setup') + settings.refresh_from_db() + self.assertTrue('verify_success' in settings.notifications) + self.assertTrue(settings.notifications['verify_success']) + self.assertFalse(settings.notifications['verify_fail']) + + def test_tier_seed(self): + """ + Run the tier seed command and check that it works as intended + + May need to change numbers as the command gets more tiers + """ + call_command('tier_seed', stdout=StringIO()) + + self.assertTrue(models.Tier.objects.exists()) + self.assertEqual(models.Tier.objects.count(), 1) + self.assertEqual(models.Tier.objects.first().max_item_level, 605) # Asphodelos diff --git a/backend/api/tests/test_notification.py b/backend/api/tests/test_notification.py new file mode 100644 index 00000000..df77c4d9 --- /dev/null +++ b/backend/api/tests/test_notification.py @@ -0,0 +1,154 @@ +from datetime import timedelta +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from api.models import Notification +from .test_base import SavageAimTestCase + + +class NotificationCollection(SavageAimTestCase): + """ + Get a list of Notifications and make sure the correct list is returned + """ + + def tearDown(self): + Notification.objects.all().delete() + + def test_list(self): + """ + Create some Notification objects in the DB, send an api request and ensure that they all are returned correctly + """ + mid_notif = Notification.objects.create( + link='/link/', + text='This is a Test Notification', + type='status_message', + user=self._get_user(), + ) + mid_notif.timestamp = timezone.now() - timedelta(hours=2) + mid_notif.save() + Notification.objects.create( + link='/link/new/', + text='This is a Test Notification too but it comes later', + type='status_message', + user=self._get_user(), + ) + old_notif = Notification.objects.create( + link='/link/old/', + text='This is a Test Notification but is the earliest and therefore is last in the list', + type='status_message', + user=self._get_user(), + ) + old_notif.timestamp = timezone.now() - timedelta(days=1) + old_notif.save() + + order = ['/link/new/', '/link/', '/link/old/'] + + url = reverse('api:notification_collection') + user = self._get_user() + self.client.force_authenticate(user) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + for index, item in enumerate(response.json()): + self.assertEqual(item['link'], order[index]) + + # Add filters and test as well + filter_url = f'{url}?unread=true' + old_notif.read = True + old_notif.save() + response = self.client.get(filter_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + self.assertEqual(len(content), 2) + self.assertTrue(old_notif.pk not in map(lambda item: item['id'], content)) + + filter_url = f'{url}?limit=2' + old_notif.read = False + old_notif.save() + response = self.client.get(filter_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + self.assertEqual(len(content), 2) + self.assertTrue(old_notif.pk not in map(lambda item: item['id'], content)) + + filter_url = f'{url}?limit=2&unread=1' + mid_notif.read = True + mid_notif.save() + response = self.client.get(filter_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + self.assertEqual(len(content), 2) + self.assertTrue(mid_notif.pk not in map(lambda item: item['id'], content)) + + def test_mark_as_read(self): + """ + Create two notifications, ensure they are unread, then send a request to mark them both as read and ensure they + are updated accordingly + """ + url = reverse('api:notification_collection') + user = self._get_user() + self.client.force_authenticate(user) + + notif1 = Notification.objects.create( + link='/link/new/', + text='This is a Test Notification', + type='status_message', + user=user, + ) + notif2 = Notification.objects.create( + link='/link/new/', + text='This is a Test Notification', + type='status_message', + user=user, + ) + self.assertFalse(notif1.read) + self.assertFalse(notif2.read) + + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + notif1.refresh_from_db() + notif2.refresh_from_db() + self.assertTrue(notif1.read) + self.assertTrue(notif2.read) + + +class NotificationResource(SavageAimTestCase): + """ + Testing the individual notification methods + """ + + def tearDown(self): + Notification.objects.all().delete() + + def test_mark_as_read(self): + """ + Create two notifications, ensure they are unread, then send a request to mark them both as read and ensure they + are updated accordingly + """ + user = self._get_user() + self.client.force_authenticate(user) + + notif1 = Notification.objects.create( + link='/link/new/', + text='This is a Test Notification', + type='status_message', + user=user, + ) + notif2 = Notification.objects.create( + link='/link/new/', + text='This is a Test Notification', + type='status_message', + user=user, + ) + self.assertFalse(notif1.read) + self.assertFalse(notif2.read) + + url = reverse('api:notification_resource', kwargs={'pk': notif2.pk}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + notif1.refresh_from_db() + notif2.refresh_from_db() + self.assertFalse(notif1.read) + self.assertTrue(notif2.read) diff --git a/backend/api/tests/test_team.py b/backend/api/tests/test_team.py index 3c5f371e..7490bccb 100644 --- a/backend/api/tests/test_team.py +++ b/backend/api/tests/test_team.py @@ -5,7 +5,7 @@ from django.urls import reverse from rest_framework import status # local -from api.models import BISList, Character, Gear, Job, Team, TeamMember, Tier +from api.models import BISList, Character, Gear, Notification, Job, Team, TeamMember, Tier from api.serializers import TeamSerializer from .test_base import SavageAimTestCase @@ -341,6 +341,7 @@ def tearDown(self): """ Clean up the DB after each test """ + Notification.objects.all().delete() TeamMember.objects.all().delete() Team.objects.all().delete() BISList.objects.all().delete() @@ -412,7 +413,7 @@ def test_update(self): data = { 'name': 'Updated Team Name', 'tier_id': new_tier.id, - 'raid_lead': char.id, + 'team_lead': char.id, } response = self.client.put(url, data) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT, response.content) @@ -426,6 +427,14 @@ def test_update(self): self.tm.refresh_from_db() self.assertFalse(self.tm.lead) + # Ensure the new character got a notification + self.assertEqual(Notification.objects.filter(user=char.user).count(), 1) + notif = Notification.objects.filter(user=char.user).first() + self.assertEqual(notif.link, f'/team/{self.team.id}/') + self.assertEqual(notif.text, f'{char} has been made the Team Leader of {self.team.name}!') + self.assertEqual(notif.type, 'team_lead') + self.assertFalse(notif.read) + def test_update_400(self): """ Send invalid update requests and ensure the right errors are returned from each request @@ -435,9 +444,9 @@ def test_update_400(self): Tier ID Not Sent: 'This field is required.' Tier ID Not Int: 'A valid integer is required.' Tier ID Invalid: 'Please select a valid Tier.' - Raid Lead Not Sent: 'This field is required.' - Raid Lead Not Int: 'A valid integer is required.' - Raid Lead Invalid: 'Please select a member of the Team to be the new raid lead.' + Team Lead Not Sent: 'This field is required.' + Team Lead Not Int: 'A valid integer is required.' + Team Lead Invalid: 'Please select a member of the Team to be the new team lead.' """ user = self._get_user() self.client.force_authenticate(user) @@ -448,32 +457,32 @@ def test_update_400(self): content = response.json() self.assertEqual(content['name'], ['This field is required.']) self.assertEqual(content['tier_id'], ['This field is required.']) - self.assertEqual(content['raid_lead'], ['This field is required.']) + self.assertEqual(content['team_lead'], ['This field is required.']) data = { 'name': 'abcde' * 100, 'tier_id': 'abcde', - 'raid_lead': 'abcde', + 'team_lead': 'abcde', } response = self.client.put(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.content) content = response.json() self.assertEqual(content['name'], ['Ensure this field has no more than 64 characters.']) self.assertEqual(content['tier_id'], ['A valid integer is required.']) - self.assertEqual(content['raid_lead'], ['A valid integer is required.']) + self.assertEqual(content['team_lead'], ['A valid integer is required.']) data = { 'name': 'Hi c:', 'tier_id': 123, - 'raid_lead': 123, + 'team_lead': 123, } response = self.client.put(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.content) content = response.json() self.assertEqual(content['tier_id'], ['Please select a valid Tier.']) - self.assertEqual(content['raid_lead'], ['Please select a member of the Team to be the new raid lead.']) + self.assertEqual(content['team_lead'], ['Please select a member of the Team to be the new team lead.']) - # Run the raid lead test again with a valid character id that isn't on the team + # Run the team lead test again with a valid character id that isn't on the team char = Character.objects.create( avatar_url='https://img.savageaim.com/abcde', lodestone_id=1348724213, @@ -484,12 +493,12 @@ def test_update_400(self): data = { 'name': 'Hi c:', 'tier_id': Tier.objects.first().pk, - 'raid_lead': char.id, + 'team_lead': char.id, } response = self.client.put(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.content) content = response.json() - self.assertEqual(content['raid_lead'], ['Please select a member of the Team to be the new raid lead.']) + self.assertEqual(content['team_lead'], ['Please select a member of the Team to be the new team lead.']) def test_404(self): """ @@ -498,7 +507,7 @@ def test_404(self): - ID doesn't exist - Read request from someone who doesn't have a character in the Team - Update request from someone who doesn't have a character in the Team - - Update request from someone that isn't the raid lead + - Update request from someone that isn't the team lead """ user = self._get_user() self.client.force_authenticate(user) @@ -579,12 +588,12 @@ def setUp(self): name='Test Team 1', tier=Tier.objects.first(), ) - # self.tm = TeamMember.objects.create(team=self.team, character=self.char, bis_list=self.bis, lead=True) def tearDown(self): """ Clean up the DB after each test """ + Notification.objects.all().delete() TeamMember.objects.all().delete() Team.objects.all().delete() BISList.objects.all().delete() @@ -617,20 +626,68 @@ def test_join(self): """ Attempt to join a Team using a character and bis list """ - user = self._get_user() + user = self._create_user() self.client.force_authenticate(user) url = reverse('api:team_invite', kwargs={'invite_code': self.team.invite_code}) - self.char.verified = True - self.char.save() + # Link the self.char to the team for notification checking + TeamMember.objects.create(team=self.team, character=self.char, bis_list=self.bis, lead=True) + + # Create new details + char = Character.objects.create( + avatar_url='https://img.savageaim.com/abcde', + lodestone_id=1234567890, + user=user, + name='Char 1', + world='Lich', + verified=True, + ) + g = Gear.objects.first() + bis = BISList.objects.create( + bis_body=g, + bis_bracelet=g, + bis_earrings=g, + bis_feet=g, + bis_hands=g, + bis_head=g, + bis_left_ring=g, + bis_legs=g, + bis_mainhand=g, + bis_necklace=g, + bis_offhand=g, + bis_right_ring=g, + current_body=g, + current_bracelet=g, + current_earrings=g, + current_feet=g, + current_hands=g, + current_head=g, + current_left_ring=g, + current_legs=g, + current_mainhand=g, + current_necklace=g, + current_offhand=g, + current_right_ring=g, + job=Job.objects.first(), + owner=char, + ) + data = { - 'character_id': self.char.id, - 'bis_list_id': self.bis.id, + 'character_id': char.id, + 'bis_list_id': bis.id, } response = self.client.post(url, data) self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.content) self.assertEqual(response.json()['id'], str(self.team.id)) + # Check that the self.user has a notification + self.assertEqual(Notification.objects.filter(user=self.char.user).count(), 1) + notif = Notification.objects.filter(user=self.char.user).first() + self.assertEqual(notif.link, f'/team/{self.team.id}/') + self.assertEqual(notif.text, f'{char} has joined {self.team.name}!') + self.assertEqual(notif.type, 'team_join') + self.assertFalse(notif.read) + def test_join_400(self): """ Attempt to join a Team using bad values and ensure 400 responses and correct errors are returned diff --git a/backend/api/tests/test_team_member.py b/backend/api/tests/test_team_member.py index 658bd4e7..6a5c040c 100644 --- a/backend/api/tests/test_team_member.py +++ b/backend/api/tests/test_team_member.py @@ -33,7 +33,7 @@ def setUp(self): avatar_url='https://img.savageaim.com/abcde', lodestone_id=1234567890, user=self._get_user(), - name='Raid Lead', + name='Team Lead', verified=True, world='Lich', ) diff --git a/backend/api/tests/test_user.py b/backend/api/tests/test_user.py index fcfaafb5..680ebd0e 100644 --- a/backend/api/tests/test_user.py +++ b/backend/api/tests/test_user.py @@ -1,6 +1,7 @@ from django.urls import reverse from rest_framework import status from .test_base import SavageAimTestCase +from api.models import Settings class User(SavageAimTestCase): @@ -29,6 +30,14 @@ def test_authenticated_user(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.json()['id'], user.id) + self.assertTrue(response.json()['notifications']['verify_fail']) + + # Add a notification to the settings and check again + Settings.objects.create(user=user, theme='beta', notifications={'verify_fail': False}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.json()['notifications']['verify_fail']) + self.assertTrue(response.json()['notifications']['verify_success']) def test_update(self): """ @@ -39,18 +48,22 @@ def test_update(self): user = self._get_user() self.client.force_authenticate(user) - data = {'theme': 'blue'} + data = {'theme': 'blue', 'notifications': {'verify_fail': False}} response = self.client.put(url, data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) user.refresh_from_db() self.assertEqual(user.settings.theme, 'blue') + self.assertFalse(user.settings.notifications['verify_fail']) # Run it again to hit the other block - data = {'theme': 'purple'} + data = {'theme': 'purple', 'notifications': {'verify_success': True}} response = self.client.put(url, data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) user.refresh_from_db() self.assertEqual(user.settings.theme, 'purple') + self.assertFalse(user.settings.notifications['verify_fail']) + self.assertTrue(user.settings.notifications['verify_success']) + self.assertTrue('team_lead' not in user.settings.notifications) def test_update_400(self): """ @@ -60,10 +73,16 @@ def test_update_400(self): user = self._get_user() self.client.force_authenticate(user) - data = {'theme': 'abcde'} + data = {'theme': 'abcde', 'notifications': {'abcde': 'abcde'}} response = self.client.put(url, data) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.json()['theme'], ['"abcde" is not a valid choice.']) + self.assertEqual(response.json()['notifications'], ['"abcde" is not a valid choice.']) + + data['notifications'] = {'team_lead': 'abcde'} + response = self.client.put(url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json()['notifications'], ['"team_lead" does not have a boolean for a value.']) def test_update_403(self): """ diff --git a/backend/api/urls.py b/backend/api/urls.py index 900903fa..dec21d8c 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -22,6 +22,10 @@ path('team//loot/', views.LootCollection.as_view(), name='loot_collection'), path('team//loot/bis/', views.LootWithBIS.as_view(), name='loot_with_bis'), + # Notifications + path('notifications/', views.NotificationCollection.as_view(), name='notification_collection'), + path('notifications//', views.NotificationResource.as_view(), name='notification_resource'), + # Team path('team/', views.TeamCollection.as_view(), name='team_collection'), path('team//', views.TeamResource.as_view(), name='team_resource'), diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py index b4921276..5b7df685 100644 --- a/backend/api/views/__init__.py +++ b/backend/api/views/__init__.py @@ -3,6 +3,7 @@ from .gear import GearCollection, ItemLevels from .job import JobCollection from .loot import LootCollection, LootWithBIS +from .notification import NotificationCollection, NotificationResource from .team import TeamCollection, TeamResource, TeamInvite from .team_member import TeamMemberResource from .tier import TierCollection @@ -24,6 +25,9 @@ 'LootCollection', 'LootWithBIS', + 'NotificationCollection', + 'NotificationResource', + 'TeamCollection', 'TeamResource', 'TeamInvite', diff --git a/backend/api/views/character.py b/backend/api/views/character.py index 640b37e5..10376031 100644 --- a/backend/api/views/character.py +++ b/backend/api/views/character.py @@ -72,7 +72,7 @@ def get(self, request: Request, pk: int) -> Response: # Delete a character. # Can only delete a character owned by the requesting user. - # If deleting someone that is a raidlead, move the raidlead to someone else in the team + # If deleting someone that is a teamlead, move the teamlead to someone else in the team # """ # try: # obj = Character.objects.get(pk=pk, user=request.user) diff --git a/backend/api/views/loot.py b/backend/api/views/loot.py index 808f1044..5b00b3ce 100644 --- a/backend/api/views/loot.py +++ b/backend/api/views/loot.py @@ -14,6 +14,7 @@ from rest_framework.request import Request from rest_framework.response import Response # local +from api import notifier from api.models import BISList, Team, Loot from api.serializers import ( LootSerializer, @@ -274,4 +275,8 @@ def post(self, request: Request, team_id: str) -> Response: bis_item = getattr(bis, f'bis_{item}') setattr(bis, f'current_{item}', bis_item) bis.save() + + # Send a notification + notifier.loot_tracker_update(bis, team) + return Response({'id': loot.pk}, status=201) diff --git a/backend/api/views/notification.py b/backend/api/views/notification.py new file mode 100644 index 00000000..3b5ed87f --- /dev/null +++ b/backend/api/views/notification.py @@ -0,0 +1,60 @@ +""" +Views to interact with Notification system +""" + +# lib +from rest_framework.views import APIView +from rest_framework.request import Request +from rest_framework.response import Response +# local +from api.models import Notification +from api.serializers import ( + NotificationSerializer, +) + + +class NotificationCollection(APIView): + """ + Retrieve a list of Notifications + Send a post request to mark all your notifications as read + """ + + def get(self, request: Request) -> Response: + """ + List the Notifications + """ + # Get the filters from the query parameters + unread = request.query_params.get('unread', False) + limit = request.query_params.get('limit', None) + + objs = Notification.objects.filter(user=request.user) + if unread: + objs = objs.filter(read=False) + if limit is not None: + try: + objs = objs[:int(limit)] + except ValueError: + pass + + data = NotificationSerializer(objs, many=True).data + return Response(data) + + def post(self, request: Request) -> Response: + """ + Mark all your notifications as read + """ + Notification.objects.filter(user=request.user).update(read=True) + return Response() + + +class NotificationResource(APIView): + """ + Mark individual notifications as read + """ + + def post(self, request: Request, pk: int) -> Response: + """ + Mark specific notification as read + """ + Notification.objects.filter(user=request.user, pk=pk).update(read=True) + return Response() diff --git a/backend/api/views/team.py b/backend/api/views/team.py index 068b5b10..ad9d3f38 100644 --- a/backend/api/views/team.py +++ b/backend/api/views/team.py @@ -11,6 +11,7 @@ from rest_framework.request import Request from rest_framework.response import Response # local +from api import notifier from api.models import Team, TeamMember from api.serializers import ( TeamSerializer, @@ -47,7 +48,7 @@ def get(self, request: Request) -> Response: def post(self, request: Request) -> Response: """ - Create a new team, with the data for the raid lead team member + Create a new team, with the data for the team lead team member """ # Ensure the data we were sent is valid serializer = TeamCreateSerializer(data=request.data, context={'user': request.user}) @@ -92,7 +93,7 @@ def get(self, request: Request, pk: str) -> Response: def put(self, request: Request, pk: str) -> Response: """ Update some data about the Team - This request can only be run by the user whose character is the raid lead + This request can only be run by the user whose character is the team lead """ try: obj = Team.objects.get(pk=pk, members__character__user=request.user, members__lead=True) @@ -102,18 +103,19 @@ def put(self, request: Request, pk: str) -> Response: serializer = TeamUpdateSerializer(instance=obj, data=request.data) serializer.is_valid(raise_exception=True) - # Pop the raid lead information from the serializer and save it, then update who the raid lead is - raid_lead_id = serializer.validated_data.pop('raid_lead') + # Pop the team lead information from the serializer and save it, then update who the team lead is + team_lead_id = serializer.validated_data.pop('team_lead') serializer.save() curr_lead = obj.members.get(lead=True) - new_lead = obj.members.get(pk=raid_lead_id) + new_lead = obj.members.get(pk=team_lead_id) if curr_lead.id != new_lead.id: # Make sure we have to do this before we run any code (don't do any unnecessary database hits) curr_lead.lead = False curr_lead.save() new_lead.lead = True new_lead.save() + notifier.team_lead(new_lead.character, obj) return Response(status=204) @@ -163,7 +165,10 @@ def post(self, request: Request, invite_code: str) -> Response: serializer.is_valid(raise_exception=True) # If we make it here, create a new Team Member object - TeamMember.objects.create(team=obj, **serializer.validated_data) + tm = TeamMember.objects.create(team=obj, **serializer.validated_data) + + # Notify the Team Lead + notifier.team_join(tm.character, obj) # Return the team id to redirect to the page return Response({'id': obj.id}, status=201) diff --git a/backend/api/views/user.py b/backend/api/views/user.py index 0ec3f71f..279a2638 100644 --- a/backend/api/views/user.py +++ b/backend/api/views/user.py @@ -47,5 +47,6 @@ def put(self, request) -> Response: serializer.is_valid(raise_exception=True) obj.theme = serializer.validated_data['theme'] + obj.notifications.update(serializer.validated_data.get('notifications', {})) obj.save() return Response(status=201) diff --git a/frontend/.env b/frontend/.env index 6b6891fc..e241bcf2 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1 @@ -VUE_APP_VERSION="0.1.2" +VUE_APP_VERSION="0.2.0" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b371a90c..c26f35d1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "frontend", "version": "0.1.0", "dependencies": { + "@creativebulma/bulma-badge": "^1.0.1", "@xivapi/js": "^0.3.3", "bulma": "^0.9.3", + "dayjs": "^1.10.7", "microtip": "^0.2.2", "range-inclusive": "^1.0.2", "vue": "^2.6.11", @@ -80,6 +82,11 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true }, + "node_modules/@creativebulma/bulma-badge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@creativebulma/bulma-badge/-/bulma-badge-1.0.1.tgz", + "integrity": "sha512-cTMgxPdTOsAfPqd9wbrvKNUTvnIt+pNgwwO9Xzu6MRUIFlvGRtIVApaV51f3wt0/uI78BpKv5FPAtEJbmSTZOg==" + }, "node_modules/@creativebulma/bulma-divider": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@creativebulma/bulma-divider/-/bulma-divider-1.1.0.tgz", @@ -3769,6 +3776,11 @@ "node": ">=0.10" } }, + "node_modules/dayjs": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz", + "integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==" + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -15890,6 +15902,11 @@ } } }, + "@creativebulma/bulma-badge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@creativebulma/bulma-badge/-/bulma-badge-1.0.1.tgz", + "integrity": "sha512-cTMgxPdTOsAfPqd9wbrvKNUTvnIt+pNgwwO9Xzu6MRUIFlvGRtIVApaV51f3wt0/uI78BpKv5FPAtEJbmSTZOg==" + }, "@creativebulma/bulma-divider": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@creativebulma/bulma-divider/-/bulma-divider-1.1.0.tgz", @@ -18863,6 +18880,11 @@ "assert-plus": "^1.0.0" } }, + "dayjs": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.7.tgz", + "integrity": "sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==" + }, "de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index c6da5d2f..8ff394aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,10 @@ "lint": "vue-cli-service lint" }, "dependencies": { + "@creativebulma/bulma-badge": "^1.0.1", "@xivapi/js": "^0.3.3", "bulma": "^0.9.3", + "dayjs": "^1.10.7", "microtip": "^0.2.2", "range-inclusive": "^1.0.2", "vue": "^2.6.11", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3388c03e..44892b3c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -65,6 +65,9 @@ export default class App extends Vue { // Check the changelog stuff this.checkChangelog() + + // Set up a window interval to poll for notifications (temp holdover until WS implementation added) + setInterval(() => { this.$store.dispatch('fetchNotifications') }, 30 * 1000) } } diff --git a/frontend/src/assets/base.scss b/frontend/src/assets/base.scss index dc41c9ac..477234e4 100644 --- a/frontend/src/assets/base.scss +++ b/frontend/src/assets/base.scss @@ -73,6 +73,7 @@ $colors: map-merge($colors, $extra-colors); $family-sans-serif: 'Raleway', BlinkMacSystemFont, -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif; @import '~bulma'; +@import "~@creativebulma/bulma-badge"; @import "~@creativebulma/bulma-divider"; @import 'microtip/microtip'; diff --git a/frontend/src/assets/gear_gradients/_index.scss b/frontend/src/assets/gear_gradients/_index.scss index b023be5b..79062502 100644 --- a/frontend/src/assets/gear_gradients/_index.scss +++ b/frontend/src/assets/gear_gradients/_index.scss @@ -3,3 +3,4 @@ @import './green.scss'; @import './purple.scss'; @import './red.scss'; +@import './trans.scss'; diff --git a/frontend/src/assets/gear_gradients/trans.scss b/frontend/src/assets/gear_gradients/trans.scss new file mode 100644 index 00000000..977d8150 --- /dev/null +++ b/frontend/src/assets/gear_gradients/trans.scss @@ -0,0 +1,38 @@ +$is-il-minus-25: #23befb; +$is-il-minus-20: #f37992; +$is-il-minus-15: #febdc7; +$is-il-minus-10: #fff; +$is-il-minus-5: #fdd4db; +$is-il-minus-0: #f7a8b8; +$is-il-bis: #55cdfc; + +table.gear-table.is-trans { + .is-il-minus-25 { + background-color: $is-il-minus-25; + color: #fff; + } + .is-il-minus-20 { + background-color: $is-il-minus-20; + color: #fff; + } + .is-il-minus-15 { + background-color: $is-il-minus-15; + color: rgba(#000, 0.7); + } + .is-il-minus-10 { + background-color: $is-il-minus-10; + color: rgba(#000, 0.7); + } + .is-il-minus-5 { + background-color: $is-il-minus-5; + color: rgba(#000, 0.7); + } + .is-il-minus-0 { + background-color: $is-il-minus-0; + color: rgba(#000, 0.7); + } + .is-il-bis { + background-color: $is-il-bis; + color: rgba(#000, 0.7); + } +} diff --git a/frontend/src/components/modals/changelog.vue b/frontend/src/components/modals/changelog.vue index e1834212..0072bae4 100644 --- a/frontend/src/components/modals/changelog.vue +++ b/frontend/src/components/modals/changelog.vue @@ -13,16 +13,25 @@

{{ version }}

expand_more Major Changes expand_more
-

We have a wiki now!

-

wiki.savageaim.com

-

Removed a lot of the help text from the pages since it's all in the wiki now!

+

Notifications System has been added!

+

+ The following events will give you notifications; +

    +
  • Character Verify requests failed or succeeded.
  • +
  • Your Character has been put in charge of a Team it is in.
  • +
  • Someone has joined a Team you lead.
  • +
  • The Loot Tracker of a Team has updated one of your BIS Lists.
  • +
+

+

These are all toggleable, so you can disable any notification types you don't want to receive by visiting the Settings page!

+

Currently the Notification system is polled every 30 seconds for updates, but I intend to make this truly live in the near future with instant updates when necessary!

expand_more Minor Changes expand_more
-

Prevented any issues forming from Teams or Characters generating duplicate tokens by preventing that from happening altogether.

-

Added wiki and github links to the footer.

-

Footer now also displays the current version.

-

Footer links now use tooltips instead of titles.

-

BIS List form filters no longer exclude currently chosen items if their item levels are outside the range of the chosen filter values.

+

Added Trans Pride colour scheme.

+

Fixed issues causing too much side padding on some pages, especially on mobile views.

+

Fixed breadcrumbs for Edit and New BIS pages linking to the wrong URL.

+

Removed mentions of "Raid Lead", only using "Team Lead" to avoid any confusion.

+

Add Badges to Character cards on the Loot Tracker pages to indicate how much Loot they have already received in this Tier.

diff --git a/frontend/src/components/modals/notifications.vue b/frontend/src/components/modals/notifications.vue new file mode 100644 index 00000000..8c340827 --- /dev/null +++ b/frontend/src/components/modals/notifications.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frontend/src/components/nav.vue b/frontend/src/components/nav.vue index 61a965d9..60219979 100644 --- a/frontend/src/components/nav.vue +++ b/frontend/src/components/nav.vue @@ -6,7 +6,9 @@ @@ -25,16 +27,30 @@