├── backend ├── apps │ ├── __init__.py │ ├── accounts │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── test_account.py │ │ │ │ ├── test_roles.py │ │ │ │ └── test_verification_token.py │ │ │ ├── views │ │ │ │ ├── __init__.py │ │ │ │ ├── profiles │ │ │ │ │ └── __init__.py │ │ │ │ └── authentication │ │ │ │ │ └── __init__.py │ │ │ ├── utils.py │ │ │ ├── test_fixtures.py │ │ │ ├── test_signals.py │ │ │ └── fixtures.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0018_merge_20230211_2049.py │ │ │ ├── 0002_alter_account_managers.py │ │ │ ├── 0030_remove_account_role.py │ │ │ ├── 0029_alter_account_managers.py │ │ │ ├── 0006_alter_token_options.py │ │ │ ├── 0005_alter_token_token.py │ │ │ ├── 0012_alter_subscription_start_date.py │ │ │ ├── 0013_alter_subscription_finish_date.py │ │ │ ├── 0014_alter_subscription_finish_date.py │ │ │ ├── 0028_alter_account_managers.py │ │ │ ├── 0026_alter_account_bio.py │ │ │ ├── 0016_remove_account_user_id_account_token.py │ │ │ ├── 0010_remove_plan_is_avaiable_plan_is_available.py │ │ │ ├── 0021_alter_subscription_plan.py │ │ │ ├── 0034_alter_account_picture.py │ │ │ ├── 0033_alter_account_picture.py │ │ │ ├── 0015_alter_account_picture.py │ │ │ ├── 0024_remove_subscription_plan_remove_subscription_user_and_more.py │ │ │ ├── 0027_alter_account_token_alter_token_token.py │ │ │ ├── 0022_remove_subscription_plan_remove_subscription_user_and_more.py │ │ │ ├── 0032_alter_verificationtoken_user.py │ │ │ ├── 0025_alter_account_token_alter_token_token.py │ │ │ ├── 0003_alter_account_bio_alter_account_picture.py │ │ │ ├── 0019_alter_plan_token_alter_subscription_token_and_more.py │ │ │ ├── 0017_alter_plan_token_alter_subscription_token_and_more.py │ │ │ ├── 0020_alter_plan_token_alter_subscription_token_and_more.py │ │ │ ├── 0008_plan.py │ │ │ ├── 0004_token.py │ │ │ └── 0009_alter_plan_active_months_alter_plan_description_and_more.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── role.py │ │ │ └── account.py │ │ ├── throttling.py │ │ ├── apps.py │ │ ├── validators.py │ │ ├── serializers │ │ │ ├── __init__.py │ │ │ └── profile.py │ │ ├── templates │ │ │ └── emails │ │ │ │ └── verification_token.html │ │ ├── admin.py │ │ ├── permissions.py │ │ ├── tasks.py │ │ ├── urls.py │ │ └── signals.py │ ├── channels │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── views │ │ │ │ ├── __init__.py │ │ │ │ ├── test_list.py │ │ │ │ └── test_delete.py │ │ │ ├── test_fixtures.py │ │ │ └── fixtures.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0011_merge_20230211_2032.py │ │ │ ├── 0014_delete_channeladmin.py │ │ │ ├── 0016_delete_channelsubscriber.py │ │ │ ├── 0002_channel_is_verified.py │ │ │ ├── 0008_channeladmin_block_user.py │ │ │ ├── 0018_alter_channel_token.py │ │ │ ├── 0012_channeladmin_unique_channel_admin.py │ │ │ ├── 0017_alter_channel_token.py │ │ │ ├── 0019_rename_profile_channel_avatar_and_more.py │ │ │ ├── 0013_alter_channeladmin_channel.py │ │ │ ├── 0010_alter_channel_token_alter_channeladmin_token.py │ │ │ ├── 0009_alter_channel_profile_alter_channel_thumbnail.py │ │ │ ├── 0020_alter_channel_avatar_alter_channel_thumbnail.py │ │ │ ├── 0021_alter_channel_avatar_alter_channel_thumbnail.py │ │ │ └── 0015_alter_channelsubscriber_channel_and_more.py │ │ ├── exceptions.py │ │ ├── apps.py │ │ ├── admin.py │ │ ├── urls.py │ │ ├── templates │ │ │ └── emails │ │ │ │ └── notify_creation.html │ │ ├── permissions.py │ │ └── signals.py │ ├── comments │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── views │ │ │ │ └── __init__.py │ │ │ ├── test_fixtures.py │ │ │ ├── fixtures.py │ │ │ └── test_models.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0002_alter_comment_token.py │ │ │ ├── 0003_alter_comment_token.py │ │ │ ├── 0004_alter_comment_token.py │ │ │ ├── 0006_alter_comment_token.py │ │ │ └── 0005_alter_comment_token.py │ │ ├── apps.py │ │ ├── exceptions.py │ │ ├── admin.py │ │ ├── tasks.py │ │ ├── signals.py │ │ └── urls.py │ ├── contents │ │ ├── __init__.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ ├── utils │ │ │ │ ├── __init__.py │ │ │ │ ├── file_path.py │ │ │ │ └── thumbnail_path.py │ │ │ └── visibility.py │ │ ├── managers.py │ │ └── serializers.py │ ├── core │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ └── fixtures │ │ │ │ ├── __init__.py │ │ │ │ ├── api_client.py │ │ │ │ └── file.py │ │ ├── tasks │ │ │ ├── __init__.py │ │ │ └── email.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── token.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ └── cache │ │ │ │ ├── __init__.py │ │ │ │ └── main.py │ │ ├── constants.py │ │ ├── utils │ │ │ ├── __init__.py │ │ │ ├── object_source.py │ │ │ ├── cache_key.py │ │ │ └── content_type_model.py │ │ ├── views.py │ │ └── permissions.py │ ├── musics │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── views │ │ │ │ └── __init__.py │ │ │ ├── test_fixtures.py │ │ │ └── fixtures.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0002_alter_music_token.py │ │ │ ├── 0006_alter_music_thumbnail.py │ │ │ ├── 0005_alter_music_thumbnail.py │ │ │ ├── 0004_alter_music_file_alter_music_thumbnail.py │ │ │ └── 0003_remove_music_music_music_file_alter_music_thumbnail.py │ │ ├── constants.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── templates │ │ │ └── emails │ │ │ │ └── notify_music_creation.html │ │ ├── urls.py │ │ ├── validators.py │ │ ├── models.py │ │ └── signals.py │ ├── videos │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── views │ │ │ │ └── __init__.py │ │ │ ├── test_fixtures.py │ │ │ └── fixtures.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0003_video_allow_comment.py │ │ │ ├── 0002_alter_video_token.py │ │ │ ├── 0005_alter_video_token.py │ │ │ ├── 0006_alter_video_token.py │ │ │ ├── 0008_alter_video_token.py │ │ │ ├── 0007_alter_video_token.py │ │ │ ├── 0004_alter_video_thumbnail.py │ │ │ ├── 0012_alter_video_thumbnail.py │ │ │ ├── 0011_alter_video_thumbnail.py │ │ │ ├── 0010_alter_video_file_alter_video_thumbnail.py │ │ │ └── 0009_remove_video_video_video_file_alter_video_thumbnail.py │ │ ├── constants.py │ │ ├── throttling.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── templates │ │ │ └── emails │ │ │ │ └── notify_video_creation.html │ │ ├── urls.py │ │ ├── validators.py │ │ ├── models.py │ │ └── signals.py │ ├── viewers │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── managers │ │ │ │ ├── __init__.py │ │ │ │ ├── test_get_count.py │ │ │ │ └── test_create_in_cache.py │ │ │ ├── views │ │ │ │ └── __init__.py │ │ │ ├── fixtures.py │ │ │ ├── test_models.py │ │ │ └── test_fixtures.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0002_viewer_channel.py │ │ ├── services.py │ │ ├── constants.py │ │ ├── apps.py │ │ ├── admin.py │ │ ├── serializers.py │ │ ├── urls.py │ │ ├── managers.py │ │ ├── signals.py │ │ ├── decorators.py │ │ ├── views.py │ │ └── models.py │ ├── votes │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── views │ │ │ │ └── __init__.py │ │ │ ├── managers │ │ │ │ ├── __init__.py │ │ │ │ ├── test_get_from_cache.py │ │ │ │ ├── test_get_count.py │ │ │ │ ├── test_create_in_cache.py │ │ │ │ └── test_delete_in_cache.py │ │ │ ├── fixtures.py │ │ │ ├── test_fixtures.py │ │ │ └── test_model.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0002_vote_channel_vote_date_vote_token.py │ │ ├── services.py │ │ ├── constants.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── vote_choice.py │ │ ├── apps.py │ │ ├── admin.py │ │ ├── managers.py │ │ ├── signals.py │ │ ├── urls.py │ │ ├── permissions.py │ │ └── serializers.py │ ├── memberships │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── views │ │ │ │ ├── __init__.py │ │ │ │ ├── test_list.py │ │ │ │ └── test_create.py │ │ │ ├── models │ │ │ │ └── __init__.py │ │ │ ├── fixtures.py │ │ │ └── test_fixtures.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0003_subscription_unique_subscription.py │ │ │ ├── 0005_alter_membership_token_alter_subscription_token.py │ │ │ ├── 0004_alter_membership_token_alter_subscription_token.py │ │ │ └── 0006_rename_finish_date_subscription_end_date_and_more.py │ │ ├── exceptions.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── membership.py │ │ ├── apps.py │ │ ├── admin.py │ │ ├── tasks.py │ │ ├── templates │ │ │ └── emails │ │ │ │ ├── notify_premium.html │ │ │ │ └── notify_plan_expiration.html │ │ ├── managers.py │ │ ├── urls.py │ │ ├── permissions.py │ │ └── signals.py │ ├── channel_admins │ │ ├── __init__.py │ │ ├── tests │ │ │ ├── __init__.py │ │ │ ├── models │ │ │ │ ├── __init__.py │ │ │ │ ├── test_admin_permission.py │ │ │ │ └── test_channel_admin.py │ │ │ ├── views │ │ │ │ └── __init__.py │ │ │ ├── test_fixtures.py │ │ │ ├── fixtures.py │ │ │ └── test_signals.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── permission.py │ │ ├── exceptions.py │ │ ├── apps.py │ │ ├── admin.py │ │ ├── templates │ │ │ └── emails │ │ │ │ └── promotion_notification.html │ │ ├── mixins.py │ │ ├── permissions.py │ │ └── urls.py │ └── channel_subscribers │ │ ├── __init__.py │ │ ├── tests │ │ ├── __init__.py │ │ ├── views │ │ │ ├── __init__.py │ │ │ └── test_list.py │ │ ├── test_managers │ │ │ ├── __init__.py │ │ │ ├── test_get_count.py │ │ │ ├── test_create_in_cache.py │ │ │ ├── test_get_from_cache.py │ │ │ └── test_delete_in_cache.py │ │ ├── test_fixtures.py │ │ ├── fixtures.py │ │ └── test_models.py │ │ ├── migrations │ │ └── __init__.py │ │ ├── services.py │ │ ├── constants.py │ │ ├── admin.py │ │ ├── serializers.py │ │ ├── apps.py │ │ ├── signals.py │ │ ├── managers.py │ │ ├── urls.py │ │ ├── models.py │ │ └── permissions.py ├── config │ ├── __init__.py │ ├── asgi.py │ ├── wsgi.py │ ├── celery.py │ ├── settings │ │ └── test.py │ └── urls.py ├── pytest.ini ├── docker │ ├── commands │ │ ├── celery.sh │ │ ├── flower.sh │ │ ├── celery-beat.sh │ │ └── setup.sh │ └── Dockerfile ├── manage.py ├── conftest.py ├── LICENSE ├── requirement.txt ├── .env.local └── .env.prod ├── frontend └── README.md ├── kubernetes ├── prometheus │ ├── service.yaml │ └── deployament.yaml ├── node_exporter │ ├── service.yaml │ └── deployament.yaml ├── redis │ ├── service.yaml │ └── deployment.yaml ├── backend │ ├── service.yaml │ └── deployment.yaml ├── postgres │ ├── service.yaml │ └── deployment.yaml ├── smtp4dev │ ├── service.yaml │ └── deployment.yaml └── celery │ └── deployment.yaml ├── .github ├── actions │ ├── deploy │ │ └── action.yaml │ ├── test-docker │ │ └── action.yaml │ └── test-kubernetes │ │ └── action.yaml └── workflows │ └── main.yaml ├── .dockerignore ├── .gitignore └── prometheus └── config.yaml /backend/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/accounts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channels/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/comments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/contents/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/musics/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/videos/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/viewers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/votes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/core/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/memberships/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/musics/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/videos/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/viewers/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channels/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/comments/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/memberships/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/musics/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/musics/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/videos/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/viewers/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/votes/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | Tsuna Streaming Frontend -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channels/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/comments/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/comments/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/memberships/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/memberships/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/viewers/tests/managers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/viewers/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/managers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/memberships/tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/views/profiles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/views/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import celery 2 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/views/authentication/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/test_managers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/apps/core/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from .email import send_email 2 | 3 | __all__ = [ 4 | "send_email", 5 | ] 6 | -------------------------------------------------------------------------------- /backend/apps/core/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .token import AbstractToken 2 | 3 | __all__ = [ 4 | "AbstractToken", 5 | ] 6 | -------------------------------------------------------------------------------- /backend/apps/core/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache import CacheService 2 | 3 | __all__ = [ 4 | "CacheService", 5 | ] 6 | -------------------------------------------------------------------------------- /backend/apps/core/constants.py: -------------------------------------------------------------------------------- 1 | from decouple import config 2 | 3 | CACHE_CONTENT_TYPE_KEY = config("CACHE_CONTENT_TYPE_KEY") 4 | -------------------------------------------------------------------------------- /backend/apps/core/services/cache/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import CacheService 2 | 3 | __all__ = [ 4 | "CacheService", 5 | ] 6 | -------------------------------------------------------------------------------- /backend/apps/votes/services.py: -------------------------------------------------------------------------------- 1 | from core.services import CacheService 2 | 3 | 4 | class VoteService(CacheService): 5 | pass 6 | -------------------------------------------------------------------------------- /backend/apps/viewers/services.py: -------------------------------------------------------------------------------- 1 | from core.services import CacheService 2 | 3 | 4 | class ViewerService(CacheService): 5 | pass 6 | -------------------------------------------------------------------------------- /backend/apps/votes/constants.py: -------------------------------------------------------------------------------- 1 | from decouple import config 2 | 3 | # COMMON CACHE KEYS 4 | CACHE_OBJECT_VOTE = config("CACHE_OBJECT_VOTE") 5 | -------------------------------------------------------------------------------- /backend/apps/votes/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .vote_choice import VoteChoice 2 | from .vote import Vote 3 | 4 | __all__ = ["Vote", "VoteChoice"] 5 | -------------------------------------------------------------------------------- /backend/apps/viewers/constants.py: -------------------------------------------------------------------------------- 1 | from decouple import config 2 | 3 | # COMMON CACHE KEYS 4 | CACHE_OBJECT_VIEWER = config("CACHE_OBJECT_VIEWER") 5 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/services.py: -------------------------------------------------------------------------------- 1 | from core.services import CacheService 2 | 3 | 4 | class ChannelSubscriberService(CacheService): 5 | pass 6 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/constants.py: -------------------------------------------------------------------------------- 1 | from decouple import config 2 | 3 | # COMMON CACHE KEYS 4 | CACHE_SUBSCRIBER_KEY = config("CACHE_CHANNEL_SUBSCRIBER") 5 | -------------------------------------------------------------------------------- /backend/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -p no:warnings --ds=config.settings.test 3 | python_files = tests.py test_*.py *_tests.py 4 | env = 5 | TASK_ALWAYS_EAGER=True -------------------------------------------------------------------------------- /backend/apps/contents/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .content import AbstractContent, ContentVisibility 2 | 3 | __all__ = [ 4 | "AbstractContent", 5 | "ContentVisibility", 6 | ] 7 | -------------------------------------------------------------------------------- /backend/apps/core/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | from .api_client import api_client 2 | from .file import create_file 3 | 4 | __all__ = [ 5 | "api_client", 6 | "create_file", 7 | ] 8 | -------------------------------------------------------------------------------- /backend/apps/votes/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VotesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "votes" 7 | -------------------------------------------------------------------------------- /backend/apps/viewers/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ViewersConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "viewers" 7 | -------------------------------------------------------------------------------- /backend/apps/votes/models/vote_choice.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class VoteChoice(models.TextChoices): 5 | UPVOTE = ("u", "Upvote") 6 | DOWNVOTE = ("d", "Downvote") 7 | -------------------------------------------------------------------------------- /backend/apps/accounts/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import Account 2 | from .verification_token import VerificationToken 3 | 4 | __all__ = [ 5 | "Account", 6 | "VerificationToken", 7 | ] 8 | -------------------------------------------------------------------------------- /backend/apps/comments/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommentsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "comments" 7 | -------------------------------------------------------------------------------- /backend/apps/comments/exceptions.py: -------------------------------------------------------------------------------- 1 | class CommentNotAllowed(Exception): 2 | """ 3 | Raise when comments are not allowed for the object and user tries to comment. 4 | """ 5 | 6 | pass 7 | -------------------------------------------------------------------------------- /backend/apps/musics/constants.py: -------------------------------------------------------------------------------- 1 | from decouple import config 2 | 3 | MUSIC_LIMIT_NORMAL_USER = config("MUSIC_LIMIT_NORMAL_USER") 4 | MUSIC_LIMIT_PREMIUM_USER = config("MUSIC_LIMIT_PREMIUM_USER") 5 | -------------------------------------------------------------------------------- /backend/apps/videos/constants.py: -------------------------------------------------------------------------------- 1 | from decouple import config 2 | 3 | VIDEO_LIMIT_NORMAL_USER = config("VIDEO_LIMIT_NORMAL_USER") 4 | VIDEO_LIMIT_PREMIUM_USER = config("VIDEO_LIMIT_PREMIUM_USER") 5 | -------------------------------------------------------------------------------- /backend/apps/memberships/exceptions.py: -------------------------------------------------------------------------------- 1 | class MembershipInUserError(Exception): 2 | """ 3 | Exception raised when a membership is in use and tempted to be deleted. 4 | """ 5 | 6 | pass 7 | -------------------------------------------------------------------------------- /backend/apps/musics/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from musics.models import Music 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_create_music(music): 7 | assert Music.objects.count() == 1 8 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/models/test_account.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.django_db 5 | def test_full_name_property(user): 6 | assert user.full_name == user.first_name + " " + user.last_name 7 | -------------------------------------------------------------------------------- /backend/apps/channels/exceptions.py: -------------------------------------------------------------------------------- 1 | class ChannelLimitExceededException(Exception): 2 | """ 3 | Exception raised when a user tries 4 | to create a channel but has reached the limit 5 | """ 6 | 7 | pass 8 | -------------------------------------------------------------------------------- /backend/apps/contents/models/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .file_path import get_file_upload_path 2 | from .thumbnail_path import get_thumbnail_upload_path 3 | 4 | __all__ = ["get_file_upload_path", "get_thumbnail_upload_path"] 5 | -------------------------------------------------------------------------------- /backend/apps/videos/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from videos.models import Video 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_create_video(video): 7 | assert Video.objects.filter(id=video.id).count() == 1 8 | -------------------------------------------------------------------------------- /backend/apps/votes/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from votes.models import Vote 3 | 4 | 5 | @admin.register(Vote) 6 | class VoteAdmin(admin.ModelAdmin): 7 | list_display = ["user", "choice", "content_type"] 8 | -------------------------------------------------------------------------------- /backend/apps/core/tests/fixtures/api_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rest_framework.test import APIClient 3 | 4 | 5 | @pytest.fixture 6 | def api_client(): 7 | """Fixture for creating an API client""" 8 | return APIClient() 9 | -------------------------------------------------------------------------------- /backend/apps/memberships/models/__init__.py: -------------------------------------------------------------------------------- 1 | from memberships.models.membership import Membership 2 | from memberships.models.subscription import Subscription 3 | 4 | __all__ = [ 5 | "Membership", 6 | "Subscription", 7 | ] 8 | -------------------------------------------------------------------------------- /backend/apps/channels/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channels.models import Channel 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_create_channel(channel): 7 | assert Channel.objects.filter(id=channel.id).count() == 1 8 | -------------------------------------------------------------------------------- /backend/apps/musics/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from musics.models import Music 3 | 4 | 5 | @admin.register(Music) 6 | class MusicAdmin(admin.ModelAdmin): 7 | list_display = ["title", "channel", "date", "token"] 8 | -------------------------------------------------------------------------------- /backend/apps/viewers/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from viewers.models import Viewer 3 | 4 | 5 | @admin.register(Viewer) 6 | class ViewerAdmin(admin.ModelAdmin): 7 | list_display = ["user", "content_object", "date"] 8 | -------------------------------------------------------------------------------- /backend/apps/comments/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from comments.models import Comment 3 | 4 | 5 | @admin.register(Comment) 6 | class CommentAdmin(admin.ModelAdmin): 7 | list_display = ["user", "content_object", "token"] 8 | -------------------------------------------------------------------------------- /backend/apps/accounts/throttling.py: -------------------------------------------------------------------------------- 1 | from rest_framework.throttling import AnonRateThrottle 2 | 3 | 4 | class AuthenticationThrottle(AnonRateThrottle): 5 | """Throttling for Register/Verify/Login views""" 6 | 7 | scope = "authentication" 8 | -------------------------------------------------------------------------------- /backend/apps/videos/throttling.py: -------------------------------------------------------------------------------- 1 | from rest_framework.throttling import UserRateThrottle 2 | 3 | 4 | class VideoThrottle(UserRateThrottle): 5 | """ 6 | Custom throttle for video views. 7 | """ 8 | 9 | scope = "video" 10 | -------------------------------------------------------------------------------- /backend/apps/videos/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from videos.models import Video 3 | 4 | 5 | @admin.register(Video) 6 | class VideoAdmin(admin.ModelAdmin): 7 | list_display = ["title", "channel", "user", "is_published", "token"] 8 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/models/__init__.py: -------------------------------------------------------------------------------- 1 | from channel_admins.models.admin import ChannelAdmin 2 | from channel_admins.models.permission import ChannelAdminPermission 3 | 4 | __all__ = [ 5 | "ChannelAdmin", 6 | "ChannelAdminPermission", 7 | ] 8 | -------------------------------------------------------------------------------- /backend/apps/contents/models/utils/file_path.py: -------------------------------------------------------------------------------- 1 | def get_file_upload_path(instance, filename): 2 | """ 3 | Return the uploaded files path 4 | eg: files/music/1.mp3 5 | """ 6 | return f"files/{instance.__class__.__name__.lower()}/{filename}" 7 | -------------------------------------------------------------------------------- /kubernetes/prometheus/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: prometheus 5 | spec: 6 | selector: 7 | app: tsuna-streaming-prometheus 8 | ports: 9 | - protocol: TCP 10 | port: 9090 11 | targetPort: 9090 -------------------------------------------------------------------------------- /backend/apps/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .content_type_model import get_content_type_model 2 | from .object_source import ObjectSource 3 | from .cache_key import generate_cache_key 4 | 5 | 6 | __all__ = ["get_content_type_model", "ObjectSource", "generate_cache_key"] 7 | -------------------------------------------------------------------------------- /backend/docker/commands/celery.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ $ENVIRONMENT == "PRODUCTION" ]]; then 4 | LOGLEVEL="CRITICAL" 5 | else 6 | LOGLEVEL="DEBUG" 7 | fi 8 | 9 | echo "Running celery worker..." 10 | celery -A config.celery worker --loglevel=$LOGLEVEL -------------------------------------------------------------------------------- /backend/docker/commands/flower.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ $ENVIRONMENT == "PRODUCTION" ]]; then 4 | LOGLEVEL="CRITICAL" 5 | else 6 | LOGLEVEL="DEBUG" 7 | fi 8 | 9 | echo "Running flower worker..." 10 | celery -A config.celery flower --loglevel=$LOGLEVEL -------------------------------------------------------------------------------- /backend/apps/channel_admins/exceptions.py: -------------------------------------------------------------------------------- 1 | class SubscriptionRequiredException(Exception): 2 | """ 3 | Exception raised when user has not subscribed to the channel. 4 | Or user has not subscribed to the channel for a at least a day. 5 | """ 6 | 7 | pass 8 | -------------------------------------------------------------------------------- /backend/apps/musics/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MusicsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "musics" 7 | 8 | def ready(self) -> None: 9 | import musics.signals 10 | -------------------------------------------------------------------------------- /backend/apps/videos/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VideosConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "videos" 7 | 8 | def ready(self) -> None: 9 | import videos.signals 10 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channel_admins.models import ChannelAdmin 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_create_channel_admin(channel_admin): 7 | assert ChannelAdmin.objects.filter(id=channel_admin.id).exists() 8 | -------------------------------------------------------------------------------- /backend/apps/contents/models/visibility.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ContentVisibility(models.TextChoices): 5 | """ 6 | Content visibility choices 7 | """ 8 | 9 | PRIVATE = ("pr", "Private") 10 | PUBLISHED = ("pu", "Public") 11 | -------------------------------------------------------------------------------- /kubernetes/node_exporter/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: node-exporter 5 | spec: 6 | selector: 7 | app: tsuna-streaming-node-exporter 8 | ports: 9 | - protocol: TCP 10 | port: 9100 11 | targetPort: 9100 12 | -------------------------------------------------------------------------------- /backend/apps/accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "accounts" 7 | 8 | def ready(self) -> None: 9 | import accounts.signals 10 | -------------------------------------------------------------------------------- /backend/apps/channels/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ChannelsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "channels" 7 | 8 | def ready(self) -> None: 9 | import channels.signals 10 | -------------------------------------------------------------------------------- /backend/apps/contents/models/utils/thumbnail_path.py: -------------------------------------------------------------------------------- 1 | def get_thumbnail_upload_path(instance, filename): 2 | """ 3 | Return the uploaded thumbnail path 4 | eg: thumbnails/music/1.jpg 5 | """ 6 | return f"thumbnails/{instance.__class__.__name__.lower()}/{filename}" 7 | -------------------------------------------------------------------------------- /backend/docker/commands/celery-beat.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [[ $ENVIRONMENT == "PRODUCTION" ]]; then 4 | LOGLEVEL="CRITICAL" 5 | else 6 | LOGLEVEL="DEBUG" 7 | fi 8 | 9 | echo "Running celery beat worker..." 10 | celery -A config.celery beat --loglevel=$LOGLEVEL 11 | -------------------------------------------------------------------------------- /.github/actions/deploy/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Deploy The Code On Cloud" 2 | description: "Deploy The Code On Cloud" 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Fake Deployment 8 | run: echo "Deploying the service to the cloud..." 9 | shell: bash 10 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from channel_subscribers.models import ChannelSubscriber 3 | 4 | 5 | @admin.register(ChannelSubscriber) 6 | class ChannelSubscriberAdmin(admin.ModelAdmin): 7 | list_display = ["user", "channel", "date"] 8 | -------------------------------------------------------------------------------- /backend/apps/comments/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from comments.models import Comment 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_create_comment(comment): 7 | """ 8 | Test if a comment can be created 9 | """ 10 | assert Comment.objects.count() == 1 11 | -------------------------------------------------------------------------------- /backend/apps/memberships/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MembershipsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "memberships" 7 | 8 | def ready(self) -> None: 9 | import memberships.signals 10 | -------------------------------------------------------------------------------- /kubernetes/redis/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: redis 5 | spec: 6 | selector: 7 | app: tsuna-streaming-redis 8 | ports: 9 | - protocol: TCP 10 | port: 6379 11 | targetPort: 6379 12 | type: LoadBalancer -------------------------------------------------------------------------------- /backend/apps/channel_admins/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ChannelAdminsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "channel_admins" 7 | 8 | def ready(self) -> None: 9 | import channel_admins.signals 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | __pycache__ 5 | db.sqlite3 6 | accounts/profile 7 | ../.vscode/settings.json 8 | .idea/ 9 | backend/.env 10 | venv/ 11 | data/ 12 | .pytest_cache/ 13 | celerybeat-schedule 14 | channels/profile 15 | videos/user_video 16 | media/ 17 | TODO.md 18 | .vscode 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | __pycache__ 5 | db.sqlite3 6 | accounts/profile 7 | ../.vscode/settings.json 8 | .idea/ 9 | backend/.env 10 | venv/ 11 | data/ 12 | .pytest_cache/ 13 | celerybeat-schedule 14 | channels/profile 15 | videos/user_video 16 | media/ 17 | TODO.md 18 | .vscode 19 | -------------------------------------------------------------------------------- /backend/apps/channels/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channels.models import Channel 3 | 4 | 5 | @pytest.fixture(scope="class") 6 | def channel(user, django_db_setup, django_db_blocker): 7 | with django_db_blocker.unblock(): 8 | yield Channel.objects.create(title="test", owner=user) 9 | -------------------------------------------------------------------------------- /kubernetes/backend/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: backend 5 | spec: 6 | selector: 7 | app: tsuna-streaming-backend 8 | ports: 9 | - protocol: TCP 10 | port: 8000 11 | targetPort: 8000 12 | type: LoadBalancer 13 | -------------------------------------------------------------------------------- /kubernetes/postgres/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: postgres 5 | labels: 6 | app: tsuna-streaming-postgres 7 | 8 | spec: 9 | selector: 10 | app: tsuna-streaming-postgres 11 | ports: 12 | - protocol: TCP 13 | port: 5432 14 | targetPort: 5432 -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | class SubscriberListSerializer(serializers.Serializer): 5 | """ 6 | Represent a list of subscribers 7 | """ 8 | 9 | user = serializers.CharField() 10 | date = serializers.DateTimeField() 11 | -------------------------------------------------------------------------------- /prometheus/config.yaml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 1m 3 | 4 | scrape_configs: 5 | - job_name: 'prometheus' 6 | scrape_interval: 1m 7 | static_configs: 8 | - targets: ['prometheus:9090'] 9 | 10 | - job_name: 'node' 11 | static_configs: 12 | - targets: ['node_exporter:9100'] 13 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ChannelSubscribersConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "channel_subscribers" 7 | 8 | def ready(self) -> None: 9 | import channel_subscribers.signals 10 | -------------------------------------------------------------------------------- /backend/apps/accounts/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | 3 | 4 | def validate_profile_size(file): 5 | """Validate profile picture size.""" 6 | limit = 5242880 7 | 8 | if file.size > limit: 9 | raise ValidationError(f"Profile picture can not be larger than 5 MB.") 10 | -------------------------------------------------------------------------------- /backend/docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-alpine 2 | 3 | ENV PYTHONUNBUFFERED=1 4 | 5 | WORKDIR /backend 6 | ENV PYTHONPATH=/backend 7 | 8 | RUN pip install --upgrade pip 9 | 10 | COPY backend/requirement.txt /backend 11 | 12 | RUN pip install -r requirement.txt 13 | 14 | COPY /backend /backend 15 | 16 | EXPOSE 8000 -------------------------------------------------------------------------------- /kubernetes/smtp4dev/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: smtp4dev 5 | 6 | spec: 7 | selector: 8 | app: tsuna-streaming-smtp4dev 9 | ports: 10 | - name: http 11 | port: 5000 12 | targetPort: tcp-80 13 | - name: smtp 14 | port: 25 15 | targetPort: tcp-25 16 | -------------------------------------------------------------------------------- /backend/apps/core/utils/object_source.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ObjectSource(Enum): 5 | """ 6 | Objects in cache can be stored in 2 places: 7 | - cache: the object is stored in cache 8 | - database: the object is stored in database and cache 9 | """ 10 | 11 | CACHE = "cache" 12 | DATABASE = "database" 13 | -------------------------------------------------------------------------------- /backend/docker/commands/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Adding migrations to database..." 4 | python manage.py makemigrations 5 | python manage.py migrate 6 | 7 | echo "Starting the server..." 8 | if [[ $ENVIRONMENT == "PRODUCTION" ]]; then 9 | gunicorn config.wsgi --bind 0.0.0.0:8000 10 | else 11 | python manage.py runserver 0.0.0.0:8000 12 | fi -------------------------------------------------------------------------------- /backend/apps/core/tests/fixtures/file.py: -------------------------------------------------------------------------------- 1 | from django.core.files import File 2 | import mock 3 | import pytest 4 | 5 | 6 | @pytest.fixture(scope="class") 7 | def create_file(): 8 | """ 9 | Create a mock file 10 | """ 11 | file_mock = mock.MagicMock(spec=File) 12 | file_mock.name = "sample.mp4" 13 | file_mock.size = 1 14 | return file_mock 15 | -------------------------------------------------------------------------------- /backend/apps/core/models/token.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from uuid import uuid4 3 | 4 | 5 | class AbstractToken(models.Model): 6 | """ 7 | An abstract model which provides a token field and auto fill it. 8 | """ 9 | 10 | token = models.UUIDField(default=uuid4, unique=True, editable=False) 11 | 12 | class Meta: 13 | abstract = True 14 | -------------------------------------------------------------------------------- /backend/apps/viewers/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from viewers.models import Viewer 3 | 4 | 5 | class ViewerListSerializer(serializers.ModelSerializer): 6 | user = serializers.StringRelatedField() 7 | 8 | class Meta: 9 | model = Viewer 10 | fields = ["user", "date"] 11 | read_only_fields = ["user", "date"] 12 | -------------------------------------------------------------------------------- /backend/apps/comments/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from comments.models import Comment 3 | 4 | 5 | @pytest.fixture(scope="class") 6 | def comment(video, django_db_setup, django_db_blocker): 7 | with django_db_blocker.unblock(): 8 | return Comment.objects.create( 9 | user=video.user, body="This is a comment", content_object=video 10 | ) 11 | -------------------------------------------------------------------------------- /backend/apps/viewers/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from .views import ViewerListView 3 | 4 | app_name = "viewers" 5 | 6 | V1 = [ 7 | path( 8 | "//list/", 9 | ViewerListView.as_view(), 10 | name="list", 11 | ) 12 | ] 13 | 14 | urlpatterns = [ 15 | path("v1/", include(V1)), 16 | ] 17 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/utils.py: -------------------------------------------------------------------------------- 1 | from faker import Faker 2 | 3 | faker = Faker() 4 | 5 | 6 | def user_credentials(password: str = "1234PassWord") -> dict: 7 | """Return user credentials.""" 8 | return { 9 | "email": faker.email(), 10 | "password": password, 11 | "first_name": faker.first_name(), 12 | "last_name": faker.last_name(), 13 | } 14 | -------------------------------------------------------------------------------- /backend/apps/accounts/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .authentication import ( 2 | RegisterUserSerializer, 3 | VerifyUserSerializer, 4 | ResendTokenSerializer, 5 | ) 6 | from .profile import ( 7 | ProfileSerializer, 8 | ) 9 | 10 | __all__ = [ 11 | "RegisterUserSerializer", 12 | "VerifyUserSerializer", 13 | "ResendTokenSerializer", 14 | "ProfileSerializer", 15 | ] 16 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/signals.py: -------------------------------------------------------------------------------- 1 | from channel_subscribers.models import ChannelSubscriber 2 | 3 | 4 | def create_subscriber_after_creating_channel(sender, created, instance, **kwargs): 5 | """ 6 | Create a new subscriber for channel owner after creating it 7 | """ 8 | 9 | if created: 10 | ChannelSubscriber.objects.create(channel=instance, user=instance.owner) 11 | -------------------------------------------------------------------------------- /backend/apps/channels/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from channels.models import Channel 3 | 4 | 5 | @admin.register(Channel) 6 | class ChannelAdmin(admin.ModelAdmin): 7 | list_display = ["title", "owner", "token", "is_verified"] 8 | 9 | def has_change_permission(self, request, obj=None): 10 | """No body can change info in admin panel""" 11 | return False 12 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0018_merge_20230211_2049.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 17:19 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0011_subscription"), 9 | ("accounts", "0017_alter_plan_token_alter_subscription_token_and_more"), 10 | ] 11 | 12 | operations = [] 13 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0011_merge_20230211_2032.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 17:02 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0002_channel_is_verified"), 9 | ("channels", "0010_alter_channel_token_alter_channeladmin_token"), 10 | ] 11 | 12 | operations = [] 13 | -------------------------------------------------------------------------------- /backend/config/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for config project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | 15 | application = get_asgi_application() 16 | -------------------------------------------------------------------------------- /backend/config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for config project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /backend/apps/accounts/templates/emails/verification_token.html: -------------------------------------------------------------------------------- 1 | {% block subject %} 2 | Verify Your Account on Tsuna Streaming 3 | {% endblock subject %} 4 | 5 | {% block html_body %} 6 |

Hello {{ first_name }},

7 |

Please click on the link below to verify your account.

8 | 9 | Verification Link 10 | 11 | {% endblock html_body %} 12 | -------------------------------------------------------------------------------- /backend/apps/videos/templates/emails/notify_video_creation.html: -------------------------------------------------------------------------------- 1 | {% block subject %} 2 | New video added to your channel. 3 | {% endblock subject %} 4 | 5 | {% block html_body %} 6 |

Hello dear {{ first_name }},

7 |

A new video added to {{ channel_title }}!

8 | 9 | 10 | Video Link 11 | 12 | 13 | {% endblock html_body %} -------------------------------------------------------------------------------- /backend/apps/channels/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from channels.views import ChannelListCreateView, ChannelDetailView 3 | 4 | 5 | app_name = "channels" 6 | 7 | V1 = [ 8 | path("", ChannelListCreateView.as_view(), name="list-create"), 9 | path("/", ChannelDetailView.as_view(), name="channel_detail"), 10 | ] 11 | 12 | urlpatterns = [ 13 | path("v1/", include(V1)), 14 | ] 15 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0014_delete_channeladmin.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-28 13:22 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0013_alter_channeladmin_channel"), 9 | ] 10 | 11 | operations = [ 12 | migrations.DeleteModel( 13 | name="ChannelAdmin", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /backend/apps/musics/templates/emails/notify_music_creation.html: -------------------------------------------------------------------------------- 1 | {% block subject %} 2 | New music added to your channel. 3 | {% endblock subject %} 4 | 5 | {% block html_body %} 6 |

Hello dear {{ first_name }},

7 |

A new music added to {{ channel_title }}!

8 | 9 | 10 | 11 | Music Link 12 | 13 | 14 | {% endblock html_body %} -------------------------------------------------------------------------------- /backend/apps/accounts/tests/models/test_roles.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.django_db 5 | class TestAccountRole: 6 | def test_superuser_is_admin(self, superuser): 7 | assert superuser.is_admin() 8 | 9 | def test_active_user_is_normal(self, user): 10 | assert user.is_normal() 11 | 12 | def test_user_with_subscription_is_premium(self, premium_user): 13 | assert premium_user.is_premium() 14 | -------------------------------------------------------------------------------- /backend/apps/memberships/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Membership, Subscription 3 | 4 | 5 | @admin.register(Membership) 6 | class MembershipAdmin(admin.ModelAdmin): 7 | list_display = ["title", "price", "active_months", "is_available"] 8 | 9 | 10 | @admin.register(Subscription) 11 | class SubscriptionAdmin(admin.ModelAdmin): 12 | list_display = ["user", "membership", "end_date", "token"] 13 | -------------------------------------------------------------------------------- /backend/apps/memberships/tasks.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from celery import shared_task 3 | from memberships.models import Subscription 4 | 5 | 6 | @shared_task 7 | def auto_delete_invalid_subscription(): 8 | """ 9 | Delete expired subscriptions. 10 | """ 11 | now = timezone.now() 12 | # Retrieve invalid subscriptions and delete them 13 | Subscription.objects.filter(end_date__lte=now).delete() 14 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from channel_admins.models import ChannelAdmin, ChannelAdminPermission 3 | 4 | 5 | @admin.register(ChannelAdmin) 6 | class ChannelAdminAdmin(admin.ModelAdmin): 7 | list_display = ["user", "channel", "date"] 8 | 9 | 10 | @admin.register(ChannelAdminPermission) 11 | class ChannelAdminPermissionAdmin(admin.ModelAdmin): 12 | list_display = ["admin", "token"] 13 | -------------------------------------------------------------------------------- /backend/apps/memberships/templates/emails/notify_premium.html: -------------------------------------------------------------------------------- 1 | {% block subject %} 2 | Your subscription has started! 3 | {% endblock subject %} 4 | 5 | {% block html_body %} 6 |

Dear {{ first_name }},

7 |

8 | We're happy to inform you that your subscription to {{ membership_title }} has started. 9 | From now on, you can enjoy all the benefits of your new plan. 10 |

11 | 12 | 13 | 14 | {% endblock html_body %} -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0002_alter_account_managers.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-03 09:44 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelManagers( 13 | name="account", 14 | managers=[], 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /backend/apps/contents/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from contents.models.visibility import ContentVisibility 3 | 4 | 5 | class BaseContentManager(models.Manager): 6 | def get_queryset(self): 7 | return super().get_queryset() 8 | 9 | def published(self): 10 | """ 11 | Return only published contents. 12 | """ 13 | return self.get_queryset().filter(visibility=ContentVisibility.PUBLISHED) 14 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0030_remove_account_role.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-16 09:49 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0029_alter_account_managers"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="account", 14 | name="role", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channel_admins.models import ChannelAdmin 3 | 4 | 5 | @pytest.fixture(scope="class") 6 | def channel_admin(channel, subscriber, django_db_setup, django_db_blocker): 7 | with django_db_blocker.unblock(): 8 | yield ChannelAdmin.objects.create( 9 | user=subscriber.user, 10 | channel=channel, 11 | promoted_by=channel.owner, 12 | ) 13 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0016_delete_channelsubscriber.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-24 09:53 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0015_alter_channelsubscriber_channel_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.DeleteModel( 13 | name="ChannelSubscriber", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /backend/apps/channels/templates/emails/notify_creation.html: -------------------------------------------------------------------------------- 1 | {% block subject %} 2 | You've created a new Channel on Tsuna Streaming! 3 | {% endblock subject %} 4 | 5 | {% block html_body %} 6 |

Hello {{ first_name }},

7 |

You have successfully created a channel on our platform!

8 | 9 | 10 | {{ channel_title }}'s profile 11 | 12 | 13 | {% endblock html_body %} -------------------------------------------------------------------------------- /backend/config/celery.py: -------------------------------------------------------------------------------- 1 | from celery import Celery 2 | import os 3 | 4 | 5 | celery = Celery("config") 6 | 7 | 8 | # Pytest sets TASK_ALWAYS_EAGER env variable for testing purposes. 9 | if os.environ.get("TASK_ALWAYS_EAGER"): 10 | # while testing, tasks wont be executed on workers. 11 | celery.conf.task_always_eager = True 12 | 13 | 14 | celery.config_from_object("django.conf:settings", namespace="CELERY") 15 | celery.autodiscover_tasks() 16 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0029_alter_account_managers.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-16 09:37 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0028_alter_account_managers"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelManagers( 13 | name="account", 14 | managers=[], 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/test_managers/test_get_count.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channel_subscribers.models import ChannelSubscriber 3 | 4 | 5 | @pytest.mark.django_db 6 | class TestSubscriberCount: 7 | def test_get_count_sub_only_owner(self, channel): 8 | assert ChannelSubscriber.objects.get_count(channel) == 1 9 | 10 | def test_get_count(self, subscriber): 11 | assert ChannelSubscriber.objects.get_count(subscriber.channel) == 2 12 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0006_alter_token_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-08 09:00 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0005_alter_token_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="token", 14 | options={"ordering": ["-date_created"]}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /backend/apps/musics/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from .views import MusicListCreateView, MusicDetailView 3 | 4 | app_name = "musics" 5 | 6 | V1 = [ 7 | path("/", MusicListCreateView.as_view(), name="list_create"), 8 | path( 9 | "//", 10 | MusicDetailView.as_view(), 11 | name="detail", 12 | ), 13 | ] 14 | 15 | urlpatterns = [ 16 | path("v1/", include(V1)), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/templates/emails/promotion_notification.html: -------------------------------------------------------------------------------- 1 | {% block subject %} 2 | You've Created a New Channel on Tsuna Streaming! 3 | {% endblock subject %} 4 | 5 | {% block html_body %} 6 |

Hello {{ first_name }},

7 |

You have successfully created a new channel on our platform!

8 | 9 | 10 | Visit {{ channel_title }}'s profile 11 | 12 | 13 | {% endblock html_body %} 14 | -------------------------------------------------------------------------------- /backend/apps/viewers/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from viewers.models import Viewer 3 | 4 | 5 | @pytest.fixture(scope="class") 6 | def viewer(video): 7 | return Viewer.objects.create(user=video.channel.owner, content_object=video) 8 | 9 | 10 | @pytest.fixture(scope="class") 11 | def cached_viewer(video): 12 | return Viewer.objects.create_in_cache( 13 | user=video.channel.owner, 14 | channel=video.channel, 15 | content_object=video, 16 | ) 17 | -------------------------------------------------------------------------------- /backend/apps/videos/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from videos.views import VideoListCreateView, VideoDetailView 3 | 4 | app_name = "videos" 5 | 6 | V1 = [ 7 | path("/", VideoListCreateView.as_view(), name="list_create"), 8 | path( 9 | "//", 10 | VideoDetailView.as_view(), 11 | name="detail", 12 | ), 13 | ] 14 | 15 | urlpatterns = [ 16 | path("v1/", include(V1)), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/mixins.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from channels.models import Channel 3 | 4 | 5 | class ChannelMixin: 6 | """ 7 | Get the channel passed in the url. 8 | """ 9 | 10 | def dispatch(self, request, *args, **kwargs): 11 | # Set channel to self.channel 12 | self.channel = get_object_or_404(Channel, token=kwargs["channel_token"]) 13 | return super(ChannelMixin, self).dispatch(request, *args, **kwargs) 14 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.django_db 5 | def test_inactive_user_is_not_active(inactive_user): 6 | assert not inactive_user.is_active 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_active_user_is_active(user): 11 | assert user.is_active 12 | 13 | 14 | @pytest.mark.django_db 15 | def test_superuser_is_superuser(superuser): 16 | assert superuser.is_superuser 17 | assert superuser.is_staff 18 | assert superuser.is_active 19 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0002_channel_is_verified.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-17 09:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="channel", 14 | name="is_verified", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0003_video_allow_comment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-02 11:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("videos", "0002_alter_video_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="video", 14 | name="allow_comment", 15 | field=models.BooleanField(default=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0005_alter_token_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-05 09:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0004_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="token", 14 | name="token", 15 | field=models.CharField(blank=True, null=True, max_length=32), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0002_alter_video_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-27 09:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("videos", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="video", 14 | name="token", 15 | field=models.CharField(blank=True, null=True, max_length=32), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from votes.models import Vote 3 | 4 | 5 | @pytest.fixture(scope="class") 6 | def vote(superuser, video, django_db_setup, django_db_blocker): 7 | with django_db_blocker.unblock(): 8 | return Vote.objects.create(user=superuser, content_object=video) 9 | 10 | 11 | @pytest.fixture() 12 | def cached_vote(superuser, video, channel): 13 | return Vote.objects.create_in_cache( 14 | user=superuser, channel=channel, content_object=video 15 | ) 16 | -------------------------------------------------------------------------------- /backend/apps/memberships/templates/emails/notify_plan_expiration.html: -------------------------------------------------------------------------------- 1 | {% block subject %} 2 | Your subscription expired. 3 | {% endblock subject %} 4 | 5 | {% block html_body %} 6 |

Dear {{ first_name }},

7 |

8 | We're sorry to inform you that your subscription to {{ membership_title }} has expired. 9 | You can renew your subscription by clicking the button below. 10 |

11 | 12 | Explore new plans 13 | 14 | 15 | {% endblock html_body %} -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0005_alter_video_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 17:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("videos", "0004_alter_video_thumbnail"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="video", 14 | name="token", 15 | field=models.CharField(blank=True, max_length=32), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/comments/migrations/0002_alter_comment_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 17:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("comments", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="comment", 14 | name="token", 15 | field=models.CharField(blank=True, max_length=32, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/memberships/managers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class SubscriptionManager(models.Manager): 5 | """ 6 | Custom manager for the Subscription model. 7 | """ 8 | 9 | def get_active_subscription(self, user: "Account"): 10 | """ 11 | Returns the active subscription for the given user. 12 | """ 13 | subscription = self.filter(user=user).first() 14 | if subscription and subscription.is_active: 15 | return subscription 16 | return 17 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0006_alter_video_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 17:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("videos", "0005_alter_video_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="video", 14 | name="token", 15 | field=models.CharField(blank=True, null=True, max_length=32), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0012_alter_subscription_start_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-14 09:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0011_subscription"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="subscription", 14 | name="start_date", 15 | field=models.DateTimeField(auto_now_add=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0008_channeladmin_block_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-24 10:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0007_channelsubscriber_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="channeladmin", 14 | name="block_user", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/comments/migrations/0003_alter_comment_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 17:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("comments", "0002_alter_comment_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="comment", 14 | name="token", 15 | field=models.CharField(blank=True, null=True, max_length=32), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/comments/migrations/0004_alter_comment_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 18:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("comments", "0003_alter_comment_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="comment", 14 | name="token", 15 | field=models.CharField(blank=True, max_length=32, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/votes/managers.py: -------------------------------------------------------------------------------- 1 | from core.managers import BaseCacheManager 2 | from votes.services import VoteService 3 | from votes.constants import CACHE_OBJECT_VOTE 4 | 5 | 6 | class VoteManager(BaseCacheManager): 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | self.service = None 10 | 11 | def contribute_to_class(self, model, name): 12 | super().contribute_to_class(model, name) 13 | self.service = VoteService(model=self.model, cache_key=CACHE_OBJECT_VOTE) 14 | -------------------------------------------------------------------------------- /backend/apps/musics/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from musics.models import Music 3 | from contents.models import ContentVisibility 4 | 5 | 6 | @pytest.fixture(scope="class") 7 | def music(channel, create_file, django_db_blocker, django_db_setup): 8 | with django_db_blocker.unblock(): 9 | yield Music.objects.create( 10 | title="test", 11 | user=channel.owner, 12 | channel=channel, 13 | file=create_file, 14 | visibility=ContentVisibility.PUBLISHED, 15 | ) 16 | -------------------------------------------------------------------------------- /backend/apps/videos/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from videos.models import Video 3 | from contents.models import ContentVisibility 4 | 5 | 6 | @pytest.fixture(scope="class") 7 | def video(channel, create_file, django_db_setup, django_db_blocker): 8 | with django_db_blocker.unblock(): 9 | yield Video.objects.create( 10 | title="test", 11 | user=channel.owner, 12 | channel=channel, 13 | file=create_file, 14 | visibility=ContentVisibility.PUBLISHED, 15 | ) 16 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0013_alter_subscription_finish_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-14 09:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0012_alter_subscription_start_date"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="subscription", 14 | name="finish_date", 15 | field=models.DateTimeField(blank=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/musics/migrations/0002_alter_music_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-15 11:29 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("musics", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="music", 15 | name="token", 16 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0014_alter_subscription_finish_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-14 09:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0013_alter_subscription_finish_date"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="subscription", 14 | name="finish_date", 15 | field=models.DateTimeField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/test_signals.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.django_db 5 | def test_token_is_created_for_inactive_user(inactive_user): 6 | """Test create token for inactive user.""" 7 | assert not inactive_user.is_active 8 | assert inactive_user.verification_tokens.count() == 1 9 | 10 | 11 | @pytest.mark.django_db 12 | def test_token_is_not_created_for_superuser(superuser): 13 | """Test not create token for superuser.""" 14 | assert superuser.is_active 15 | assert superuser.verification_tokens.count() == 0 16 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | 3 | 4 | class IsChannelOwner(BasePermission): 5 | """ 6 | Check if user is the channel owner 7 | """ 8 | 9 | message = "You are not the owner of the channel." 10 | 11 | def has_permission(self, request, view): 12 | """ 13 | Check if the user is the owner of the channel. 14 | The view.channel is provided by the `ChannelMixin` for the view 15 | """ 16 | return request.user == view.channel.owner 17 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from .views import ( 3 | AdminListCreateView, 4 | AdminDetailView, 5 | ) 6 | 7 | app_name = "channel_admins" 8 | 9 | V1 = [ 10 | path( 11 | "/", AdminListCreateView.as_view(), name="admin_list_create" 12 | ), 13 | path( 14 | "//", 15 | AdminDetailView.as_view(), 16 | name="admin_detail", 17 | ), 18 | ] 19 | 20 | urlpatterns = [ 21 | path("v1/", include(V1)), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0008_alter_video_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-15 11:29 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("videos", "0007_alter_video_token"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="video", 15 | name="token", 16 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/apps/core/views.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | 3 | 4 | def error_handler_404(request, *args, **kwargs): 5 | """ 6 | Return a message with status of 404 when page not found. 7 | """ 8 | 9 | return JsonResponse({"message": "Page not found."}, status=404) 10 | 11 | 12 | def error_handler_500(request, *args, **kwargs): 13 | """ 14 | Return a message with status of 500 when an internal server error occurred. 15 | """ 16 | 17 | return JsonResponse({"message": "An internal server error occurred."}, status=500) 18 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0018_alter_channel_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-15 11:29 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("channels", "0017_alter_channel_token"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="channel", 15 | name="token", 16 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/apps/comments/migrations/0006_alter_comment_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-15 11:29 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("comments", "0005_alter_comment_token"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="comment", 15 | name="token", 16 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0007_alter_video_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-26 10:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("videos", "0006_alter_video_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="video", 14 | name="token", 15 | field=models.CharField( 16 | blank=True, editable=False, max_length=32, null=True 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/apps/memberships/migrations/0003_subscription_unique_subscription.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-13 12:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0002_subscription"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddConstraint( 13 | model_name="subscription", 14 | constraint=models.UniqueConstraint( 15 | fields=("user",), name="unique_subscription" 16 | ), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /kubernetes/redis/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: tsuna-streaming-redis-deployment 5 | labels: 6 | app: tsuna-streaming-redis 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: tsuna-streaming-redis 12 | template: 13 | metadata: 14 | labels: 15 | app: tsuna-streaming-redis 16 | spec: 17 | containers: 18 | - name: tsuna-streaming-redis 19 | image: redis:6.2-alpine 20 | ports: 21 | - containerPort: 6379 -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channels.models import Channel 3 | from channel_subscribers.models import ChannelSubscriber 4 | 5 | 6 | @pytest.mark.django_db 7 | def test_create_subscriber(subscriber): 8 | assert subscriber.channel.subscribers.count() == 2 9 | 10 | 11 | @pytest.mark.django_db 12 | def test_create_cached_subscriber(cached_subscriber): 13 | assert ( 14 | ChannelSubscriber.objects.get_count( 15 | channel=Channel.objects.get(id=cached_subscriber["channel"]) 16 | ) 17 | == 2 18 | ) 19 | -------------------------------------------------------------------------------- /backend/apps/comments/migrations/0005_alter_comment_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-26 10:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("comments", "0004_alter_comment_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="comment", 14 | name="token", 15 | field=models.CharField( 16 | blank=True, editable=False, max_length=32, null=True 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/apps/comments/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | from comments.models import Comment 3 | from core.utils import get_content_type_model 4 | 5 | 6 | @shared_task 7 | def remove_object_comments(content_type_id: int, object_id: int): 8 | """ 9 | Remove all comments related to an object after its deletion. 10 | """ 11 | # get the object model content type (eg: Video) 12 | content_model = get_content_type_model(_id=content_type_id).id 13 | # Delete Related Comments 14 | Comment.objects.filter(content_type=content_model, object_id=object_id).delete() 15 | -------------------------------------------------------------------------------- /backend/apps/core/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | from channel_admins.models import ChannelAdmin 3 | 4 | 5 | class IsChannelAdmin(BasePermission): 6 | """ 7 | Check if user is an admin of the channel. 8 | """ 9 | 10 | message = "You are not admin of the channel" 11 | 12 | def has_permission(self, request, view): 13 | """ 14 | Check if user is an admin of the channel. 15 | """ 16 | channel = view.channel 17 | return ChannelAdmin.objects.filter(channel=channel, user=request.user).exists() 18 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/managers/test_get_from_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from votes.models import Vote 3 | from core.utils import ObjectSource 4 | from channels.models import Channel 5 | from accounts.models import Account 6 | 7 | 8 | @pytest.mark.django_db 9 | class TestGetVoteFromCache: 10 | def test_get_from_db(self, vote): 11 | vote = Vote.objects.get_from_cache( 12 | channel=vote.channel, 13 | user=vote.user, 14 | content_object=vote.content_object, 15 | ) 16 | assert vote.get("source") == ObjectSource.DATABASE.value 17 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0012_channeladmin_unique_channel_admin.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-12 15:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0011_merge_20230211_2032"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddConstraint( 13 | model_name="channeladmin", 14 | constraint=models.UniqueConstraint( 15 | fields=("channel", "user"), name="unique_channel_admin" 16 | ), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0017_alter_channel_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-26 10:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0016_delete_channelsubscriber"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="channel", 14 | name="token", 15 | field=models.CharField( 16 | blank=True, editable=False, max_length=32, null=True 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /kubernetes/node_exporter/deployament.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: tsuna-streaming-node-exporter-dp 5 | labels: 6 | app: tsuna-streaming-node-exporter 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: tsuna-streaming-node-exporter 12 | template: 13 | metadata: 14 | labels: 15 | app: tsuna-streaming-node-exporter 16 | spec: 17 | containers: 18 | - name: node-exporter 19 | image: quay.io/prometheus/node-exporter:latest 20 | ports: 21 | - containerPort: 9100 -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0028_alter_account_managers.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-16 09:33 2 | 3 | import django.contrib.auth.models 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0027_alter_account_token_alter_token_token"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelManagers( 14 | name="account", 15 | managers=[ 16 | ("objects", django.contrib.auth.models.UserManager()), 17 | ], 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/apps/viewers/tests/managers/test_get_count.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from viewers.models import Viewer 3 | from channels.models import Channel 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestViewerCount: 8 | def test_get_count_in_db(self, viewer): 9 | assert ( 10 | Viewer.objects.get_count( 11 | channel=viewer.channel, 12 | content_object=viewer.content_object, 13 | ) 14 | == 1 15 | ) 16 | 17 | def test_get_count_null(self, video): 18 | assert Viewer.objects.get_count(channel=video.channel, content_object=video) 19 | -------------------------------------------------------------------------------- /backend/apps/comments/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save, post_delete 3 | from comments.tasks import remove_object_comments 4 | from core.utils import get_content_type_model 5 | 6 | 7 | @receiver(post_delete, sender=AbstractContent) 8 | def delete_object_comments_after_deleting(sender, instance, *args, **kwargs): 9 | """ 10 | Delete object comments after deleting the object. 11 | """ 12 | remove_object_comments.delay( 13 | content_type_id=get_content_type_model(model=type(instance)), 14 | object_id=instance.id, 15 | ) 16 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channel_subscribers.models import ChannelSubscriber 3 | 4 | 5 | @pytest.fixture(scope="class") 6 | def subscriber(django_db_setup, django_db_blocker, channel, superuser): 7 | with django_db_blocker.unblock(): 8 | yield ChannelSubscriber.objects.create(user=superuser, channel=channel) 9 | 10 | 11 | @pytest.fixture(scope="class") 12 | def cached_subscriber(channel, superuser, django_db_setup, django_db_blocker): 13 | with django_db_blocker.unblock(): 14 | yield ChannelSubscriber.objects.create_in_cache(channel=channel, user=superuser) 15 | -------------------------------------------------------------------------------- /kubernetes/smtp4dev/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: smtp4dev-deployment 5 | labels: 6 | app: tsuna-streaming-smtp4dev 7 | 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: tsuna-streaming-smtp4dev 13 | template: 14 | metadata: 15 | labels: 16 | app: tsuna-streaming-smtp4dev 17 | spec: 18 | containers: 19 | - name: smtp 20 | image: rnwood/smtp4dev:v3 21 | ports: 22 | - name: tcp-80 23 | containerPort: 80 24 | - name: tcp-25 25 | containerPort: 25 -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0004_alter_video_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-10 09:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("videos", "0003_video_allow_comment"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="video", 14 | name="thumbnail", 15 | field=models.ImageField( 16 | default="assets/images/default-video-thumbnail.jpg", 17 | upload_to="videos/thumbnail/", 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0026_alter_account_bio.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-04-04 10:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0025_alter_account_token_alter_token_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="account", 14 | name="bio", 15 | field=models.TextField( 16 | blank=True, 17 | default="Hey there, i am using tsuna streaming.", 18 | max_length=250, 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/apps/viewers/managers.py: -------------------------------------------------------------------------------- 1 | from core.managers import BaseCacheManager 2 | from viewers.services import ViewerService 3 | from viewers.constants import CACHE_OBJECT_VIEWER 4 | 5 | 6 | class ViewerManager(BaseCacheManager): 7 | def __init__(self, *args, **kwargs): 8 | super().__init__(*args, **kwargs) 9 | self.service = None 10 | 11 | def contribute_to_class(self, model, name): 12 | super().contribute_to_class(model, name) 13 | self.service = ViewerService(model=self.model, cache_key=CACHE_OBJECT_VIEWER) 14 | 15 | def delete_in_cache(self): 16 | """Make delete viewer unavailable in cache.""" 17 | pass 18 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0016_remove_account_user_id_account_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 16:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0015_alter_account_picture"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="account", 14 | name="user_id", 15 | ), 16 | migrations.AddField( 17 | model_name="account", 18 | name="token", 19 | field=models.CharField(blank=True, max_length=32, null=True), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0019_rename_profile_channel_avatar_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-19 13:58 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0018_alter_channel_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="channel", 14 | old_name="profile", 15 | new_name="avatar", 16 | ), 17 | migrations.RenameField( 18 | model_name="channel", 19 | old_name="date_joined", 20 | new_name="date_created", 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/apps/accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from accounts.models import Account, VerificationToken 3 | 4 | 5 | @admin.register(Account) 6 | class AccountAdmin(admin.ModelAdmin): 7 | list_display = ["email", "is_active", "token"] 8 | 9 | def has_change_permission(self, request, obj=None): 10 | """No body can change info in admin panel""" 11 | return False 12 | 13 | 14 | @admin.register(VerificationToken) 15 | class TokenAdmin(admin.ModelAdmin): 16 | list_display = ["user", "expire_at", "is_valid"] 17 | 18 | def has_change_permission(self, request, obj=None): 19 | """No body can change info in admin panel""" 20 | return False 21 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0010_remove_plan_is_avaiable_plan_is_available.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-12 08:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0009_alter_plan_active_months_alter_plan_description_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="plan", 14 | name="is_avaiable", 15 | ), 16 | migrations.AddField( 17 | model_name="plan", 18 | name="is_available", 19 | field=models.BooleanField(default=False), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/apps/memberships/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from memberships.models import Membership, Subscription 3 | 4 | 5 | @pytest.fixture(scope="class") 6 | def membership(django_db_setup, django_db_blocker) -> Membership: 7 | with django_db_blocker.unblock(): 8 | yield Membership.objects.create( 9 | title="Fake Membership", active_months=6, is_available=True 10 | ) 11 | 12 | 13 | @pytest.fixture(scope="class") 14 | def subscription(membership, user, django_db_blocker, django_db_setup): 15 | with django_db_blocker.unblock(): 16 | yield Subscription.objects.create( 17 | user=user, 18 | membership=membership, 19 | ) 20 | -------------------------------------------------------------------------------- /backend/apps/viewers/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from viewers.models import Viewer 3 | 4 | 5 | @pytest.mark.django_db 6 | class TestViewerModel: 7 | def test_set_channel(self, viewer): 8 | """ 9 | When a viewer is created, 10 | the channel field should be set to the channel of the video. 11 | """ 12 | assert Viewer.objects.filter(id=viewer.id).count() == 1 13 | assert viewer.channel == viewer.content_object.channel 14 | 15 | def test_delete_viewer_after_deleting_object(self, viewer): 16 | viewer_id = viewer.id 17 | viewer.content_object.delete() 18 | assert Viewer.objects.filter(id=viewer_id).count() == 0 19 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /backend/apps/viewers/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save, post_delete 3 | from contents.models import AbstractContent 4 | from viewers.tasks import remove_object_viewers 5 | from core.utils import get_content_type_model 6 | 7 | 8 | @receiver(post_delete, sender=AbstractContent) 9 | def delete_object_viewers_after_deleting(sender, instance, *args, **kwargs): 10 | """ 11 | Deletes all viewers of an object after deleting it. 12 | """ 13 | 14 | remove_object_viewers.delay( 15 | content_type_id=get_content_type_model(model=type(instance)).id, 16 | object_id=instance.id, 17 | object_token=instance.token, 18 | ) 19 | -------------------------------------------------------------------------------- /backend/apps/memberships/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from memberships.views import ( 3 | MembershipListCreateView, 4 | MembershipDetailView, 5 | MembershipSubscribeView, 6 | ) 7 | 8 | app_name = "memberships" 9 | 10 | V1 = [ 11 | path("", MembershipListCreateView.as_view(), name="membership"), 12 | path( 13 | "/", 14 | MembershipDetailView.as_view(), 15 | name="membership_detail", 16 | ), 17 | path( 18 | "/subscribe/", 19 | MembershipSubscribeView.as_view(), 20 | name="membership_subscribe", 21 | ), 22 | ] 23 | 24 | urlpatterns = [ 25 | path("v1/", include(V1)), 26 | ] 27 | -------------------------------------------------------------------------------- /backend/apps/memberships/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from memberships.models import Membership, Subscription 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_membership_is_created(membership): 7 | assert Membership.objects.filter(title=membership.title).exists() 8 | 9 | 10 | @pytest.mark.django_db 11 | def test_subscription_is_created(subscription): 12 | assert Subscription.objects.filter(user=subscription.user).exists() 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_subscriptions_plan(membership, subscription): 17 | assert subscription.membership == membership 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_create_premium_user(premium_user): 22 | assert premium_user.is_premium() 23 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from votes.models import Vote 3 | from channels.models import Channel 4 | from accounts.models import Account 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_create_vote(vote): 9 | assert Vote.objects.count() == 1 10 | 11 | 12 | @pytest.mark.django_db 13 | def test_create_vote_in_cache(cached_vote): 14 | user = Account.objects.get(id=cached_vote["user"]) 15 | channel = Channel.objects.get(id=cached_vote["channel"]) 16 | assert ( 17 | Vote.objects.get_from_cache( 18 | user=user, 19 | channel=channel, 20 | content_object=cached_vote["content_object"], 21 | ) 22 | is not None 23 | ) 24 | -------------------------------------------------------------------------------- /backend/apps/core/tasks/email.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from templated_mail.mail import BaseEmailMessage 3 | from celery import shared_task 4 | 5 | 6 | @shared_task 7 | def send_email(template_name: str, to_email: str, body: dict): 8 | """ 9 | Send an email to the given user 10 | :param template_name: The name of the template to send 11 | :param to_email: The email address to send to 12 | :param body: The context to send to the template 13 | """ 14 | 15 | # Set the domain for the email 16 | body.setdefault("domain", settings.DOMAIN) 17 | 18 | # Send email to user 19 | email = BaseEmailMessage(template_name=template_name, context=body) 20 | email.send(to=[to_email]) 21 | -------------------------------------------------------------------------------- /backend/apps/musics/migrations/0006_alter_music_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-06 10:19 2 | 3 | import contents.models.utils.thumbnail_path 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("musics", "0005_alter_music_thumbnail"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="music", 15 | name="thumbnail", 16 | field=models.ImageField( 17 | default="assets/images/default-thumbnail.jpg", 18 | upload_to=contents.models.utils.thumbnail_path.get_thumbnail_upload_path, 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0012_alter_video_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-06 10:19 2 | 3 | import contents.models.utils.thumbnail_path 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("videos", "0011_alter_video_thumbnail"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="video", 15 | name="thumbnail", 16 | field=models.ImageField( 17 | default="assets/images/default-thumbnail.jpg", 18 | upload_to=contents.models.utils.thumbnail_path.get_thumbnail_upload_path, 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0013_alter_channeladmin_channel.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-20 16:23 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("channels", "0012_channeladmin_unique_channel_admin"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="channeladmin", 15 | name="channel", 16 | field=models.ForeignKey( 17 | on_delete=django.db.models.deletion.CASCADE, 18 | related_name="admin", 19 | to="channels.channel", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/apps/accounts/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | 3 | 4 | class AllowUnAuthenticatedPermission(BasePermission): 5 | """ 6 | Only allow unauthenticated users to access this view. 7 | """ 8 | 9 | message = "Authenticated users are not allowed to access this view" 10 | 11 | def has_permission(self, request, view): 12 | return not request.user.is_authenticated 13 | 14 | 15 | class CanUpdateProfile(BasePermission): 16 | message = "Only profile owner can update this profile." 17 | 18 | def has_object_permission(self, request, view, obj): 19 | if request.method in ["PUT", "PATCH"]: 20 | return obj == request.user 21 | return True 22 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0021_alter_subscription_plan.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-03 09:33 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0020_alter_plan_token_alter_subscription_token_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="subscription", 15 | name="plan", 16 | field=models.ForeignKey( 17 | on_delete=django.db.models.deletion.CASCADE, 18 | related_name="plans", 19 | to="accounts.plan", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/apps/memberships/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | 3 | 4 | class IsAdmin(BasePermission): 5 | """ 6 | Only Allow Admins to perform this action. 7 | """ 8 | 9 | message = "Only admins can perform this action" 10 | 11 | def has_permission(self, request, view): 12 | return request.user.is_admin() 13 | 14 | 15 | class CanSubscribeMembership(BasePermission): 16 | """ 17 | Only allow normal users to subscribe to a membership plan. 18 | """ 19 | 20 | message = "Only normal users can perform this action" 21 | 22 | def has_permission(self, request, view): 23 | user = request.user 24 | return not user.is_admin() and not user.is_premium() 25 | -------------------------------------------------------------------------------- /backend/apps/viewers/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from viewers.models import Viewer 3 | from accounts.models import Account 4 | from channels.models import Channel 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_create_viewer(viewer): 9 | assert Viewer.objects.count() == 1 10 | 11 | 12 | @pytest.mark.django_db 13 | def test_create_cached_viewer(cached_viewer): 14 | user = Account.objects.get(id=cached_viewer["user"]) 15 | channel = Channel.objects.get(id=cached_viewer["channel"]) 16 | assert ( 17 | Viewer.objects.get_from_cache( 18 | user=user, 19 | channel=channel, 20 | content_object=cached_viewer["content_object"], 21 | ) 22 | is not None 23 | ) 24 | -------------------------------------------------------------------------------- /backend/apps/musics/migrations/0005_alter_music_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-06 10:15 2 | 3 | import contents.models.utils.thumbnail_path 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("musics", "0004_alter_music_file_alter_music_thumbnail"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="music", 15 | name="thumbnail", 16 | field=models.ImageField( 17 | default="static/images/default-thumbnail.jpg", 18 | upload_to=contents.models.utils.thumbnail_path.get_thumbnail_upload_path, 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0011_alter_video_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-06 10:15 2 | 3 | import contents.models.utils.thumbnail_path 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("videos", "0010_alter_video_file_alter_video_thumbnail"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="video", 15 | name="thumbnail", 16 | field=models.ImageField( 17 | default="static/images/default-thumbnail.jpg", 18 | upload_to=contents.models.utils.thumbnail_path.get_thumbnail_upload_path, 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /backend/apps/viewers/migrations/0002_viewer_channel.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-30 17:23 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("channels", "0019_rename_profile_channel_avatar_and_more"), 10 | ("viewers", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="viewer", 16 | name="channel", 17 | field=models.ForeignKey( 18 | null=True, 19 | on_delete=django.db.models.deletion.CASCADE, 20 | to="channels.channel", 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0034_alter_account_picture.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-06 10:19 2 | 3 | import accounts.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0033_alter_account_picture"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="account", 15 | name="picture", 16 | field=models.ImageField( 17 | default="assets/images/default-user-profile.jpg", 18 | upload_to="accounts/profile", 19 | validators=[accounts.validators.validate_profile_size], 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0033_alter_account_picture.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-06 10:15 2 | 3 | import accounts.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0032_alter_verificationtoken_user"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="account", 15 | name="picture", 16 | field=models.ImageField( 17 | default="static/images/default-user-profile.jpg", 18 | upload_to="accounts/profile", 19 | validators=[accounts.validators.validate_profile_size], 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0015_alter_account_picture.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-10 09:40 2 | 3 | import accounts.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0014_alter_subscription_finish_date"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="account", 15 | name="picture", 16 | field=models.ImageField( 17 | default="assets/images/default-user-profile.jpg", 18 | upload_to="accounts/profile", 19 | validators=[accounts.validators.validate_profile_size], 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/apps/core/services/cache/main.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from .list import CacheListMixin 3 | from .create import CacheCreateMixin 4 | from .get import CacheGetMixin 5 | from .delete import CacheDeleteMixin 6 | 7 | 8 | class CacheService(CacheListMixin, CacheCreateMixin, CacheGetMixin, CacheDeleteMixin): 9 | """ 10 | A service for handling cache operations. 11 | Methods (from parent classes): 12 | - create_cache() 13 | - get_from_cache() 14 | - get_list() 15 | - delete_cache() 16 | """ 17 | 18 | def __init__(self, model: models.Model, cache_key: str): 19 | """ 20 | Set the default model for the service 21 | """ 22 | self.model = model 23 | self.raw_cache_key = cache_key 24 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0024_remove_subscription_plan_remove_subscription_user_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-14 12:36 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0023_plan_subscription"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="subscription", 14 | name="plan", 15 | ), 16 | migrations.RemoveField( 17 | model_name="subscription", 18 | name="user", 19 | ), 20 | migrations.DeleteModel( 21 | name="Plan", 22 | ), 23 | migrations.DeleteModel( 24 | name="Subscription", 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0027_alter_account_token_alter_token_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-15 11:29 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0026_alter_account_bio"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="account", 15 | name="token", 16 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="token", 20 | name="token", 21 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/managers.py: -------------------------------------------------------------------------------- 1 | from core.managers import BaseCacheManager 2 | from channel_subscribers.services import ChannelSubscriberService 3 | from channel_subscribers.constants import CACHE_SUBSCRIBER_KEY 4 | 5 | 6 | class ChannelSubscriberManager(BaseCacheManager): 7 | """ 8 | A manager for subscriber model which uses Subscriber Service for 9 | CRUD operations in cache 10 | """ 11 | 12 | def __init__(self, *args, **kwargs): 13 | super().__init__(*args, **kwargs) 14 | self.service = None 15 | 16 | def contribute_to_class(self, model, name): 17 | super().contribute_to_class(model, name) 18 | self.service = ChannelSubscriberService( 19 | model=self.model, cache_key=CACHE_SUBSCRIBER_KEY 20 | ) 21 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/test_managers/test_create_in_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channel_subscribers.models import ChannelSubscriber 3 | 4 | 5 | @pytest.mark.django_db 6 | class TestCreateInCache: 7 | def test_create_cached_subscriber(self, channel, superuser): 8 | ChannelSubscriber.objects.create_in_cache(channel=channel, user=superuser) 9 | assert ChannelSubscriber.objects.get_count(channel) == 2 10 | 11 | def test_create_cached_subscriber_twice(self, channel, superuser): 12 | ChannelSubscriber.objects.create_in_cache(channel=channel, user=superuser) 13 | # Second Time 14 | ChannelSubscriber.objects.create_in_cache(channel=channel, user=superuser) 15 | assert ChannelSubscriber.objects.get_count(channel) == 2 16 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/test_managers/test_get_from_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channel_subscribers.models import ChannelSubscriber 3 | from core.utils import ObjectSource 4 | from channels.models import Channel 5 | from accounts.models import Account 6 | 7 | 8 | @pytest.mark.django_db 9 | class TestGetSubFromCache: 10 | def test_get_from_db(self, subscriber): 11 | sub = ChannelSubscriber.objects.get_from_cache( 12 | channel=subscriber.channel, user=subscriber.user 13 | ) 14 | assert sub.get("source") == ObjectSource.DATABASE.value 15 | 16 | def test_get_cached(self, cached_subscriber, superuser, channel): 17 | sub = ChannelSubscriber.objects.get_from_cache(channel=channel, user=superuser) 18 | assert sub 19 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0010_alter_channel_token_alter_channeladmin_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 16:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("channels", "0009_alter_channel_profile_alter_channel_thumbnail"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="channel", 14 | name="token", 15 | field=models.CharField(blank=True, max_length=32, null=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="channeladmin", 19 | name="token", 20 | field=models.CharField(blank=True, max_length=32, null=True), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0022_remove_subscription_plan_remove_subscription_user_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-14 11:11 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0021_alter_subscription_plan"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="subscription", 14 | name="plan", 15 | ), 16 | migrations.RemoveField( 17 | model_name="subscription", 18 | name="user", 19 | ), 20 | migrations.DeleteModel( 21 | name="Plan", 22 | ), 23 | migrations.DeleteModel( 24 | name="Subscription", 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0032_alter_verificationtoken_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-16 12:02 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("accounts", "0031_verificationtoken_delete_token"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="verificationtoken", 16 | name="user", 17 | field=models.ForeignKey( 18 | on_delete=django.db.models.deletion.CASCADE, 19 | related_name="verification_tokens", 20 | to=settings.AUTH_USER_MODEL, 21 | ), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/apps/comments/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from .views import CommentListCreateView, CommentDetailView, CommentPinView 3 | 4 | app_name = "comments" 5 | 6 | V1 = [ 7 | path( 8 | "//", 9 | CommentListCreateView.as_view(), 10 | name="comment_list_create", 11 | ), 12 | path( 13 | "///", 14 | CommentDetailView.as_view(), 15 | name="comment_detail", 16 | ), 17 | path( 18 | "///pin/", 19 | CommentPinView.as_view(), 20 | name="comment_pin", 21 | ), 22 | ] 23 | 24 | urlpatterns = [ 25 | path("v1/", include(V1)), 26 | ] 27 | -------------------------------------------------------------------------------- /backend/apps/votes/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save, post_delete 3 | from contents.models import AbstractContent 4 | from votes.tasks import remove_object_votes 5 | from core.utils import get_content_type_model 6 | 7 | 8 | @receiver(post_delete, sender=AbstractContent) 9 | def delete_vote_after_deleting_object(sender, instance, *args, **kwargs): 10 | """ 11 | When an object is deleted, 12 | all the votes related to that object will be deleted too. 13 | """ 14 | 15 | # Call the celery task to delete them 16 | remove_object_votes.delay( 17 | content_type_id=get_content_type_model(model=type(instance)).id, 18 | object_id=instance.id, 19 | object_token=instance.token, 20 | ) 21 | -------------------------------------------------------------------------------- /backend/apps/accounts/tasks.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from accounts.models import Account, VerificationToken 3 | from celery import shared_task 4 | import datetime 5 | 6 | 7 | @shared_task 8 | def auto_delete_expired_tokens(): 9 | """ 10 | Auto delete expired tokens. 11 | """ 12 | 13 | now = timezone.now() 14 | 15 | # invalid tokens 16 | VerificationToken.objects.filter(expire_at__lte=now).delete() 17 | 18 | 19 | @shared_task 20 | def auto_delete_deactive_users(): 21 | """ 22 | Auto delete deactive users after one day. 23 | """ 24 | 25 | # one day before now 26 | yesterday = timezone.now() - datetime.timedelta(1) 27 | 28 | # deactivated users 29 | Account.objects.filter(date_joined__lte=yesterday, is_active=False).delete() 30 | -------------------------------------------------------------------------------- /backend/apps/musics/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.conf import settings 3 | from musics.constants import MUSIC_LIMIT_NORMAL_USER, MUSIC_LIMIT_PREMIUM_USER 4 | 5 | 6 | def validate_music_size(file, user: settings.AUTH_USER_MODEL) -> None: 7 | """ 8 | Validate file size 9 | """ 10 | file_size = file.size / (1024 * 1024) 11 | 12 | if user.is_premium() and file_size > int(MUSIC_LIMIT_PREMIUM_USER): 13 | raise ValidationError( 14 | f"Music file size should not exceed {MUSIC_LIMIT_PREMIUM_USER}MB." 15 | ) 16 | 17 | if user.is_normal() and file_size > int(MUSIC_LIMIT_NORMAL_USER): 18 | raise ValidationError( 19 | f"Normal users can upload musics up to {MUSIC_LIMIT_NORMAL_USER}MB." 20 | ) 21 | -------------------------------------------------------------------------------- /backend/apps/videos/validators.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.conf import settings 3 | from videos.constants import VIDEO_LIMIT_NORMAL_USER, VIDEO_LIMIT_PREMIUM_USER 4 | 5 | 6 | def validate_video_size(file, user: settings.AUTH_USER_MODEL) -> None: 7 | """ 8 | Validate file size 9 | """ 10 | file_size = file.size / (1024 * 1024) 11 | 12 | if user.is_premium() and file_size > int(VIDEO_LIMIT_PREMIUM_USER): 13 | raise ValidationError( 14 | f"Video file size should not exceed {VIDEO_LIMIT_PREMIUM_USER}MB." 15 | ) 16 | 17 | if user.is_normal() and file_size > int(VIDEO_LIMIT_NORMAL_USER): 18 | raise ValidationError( 19 | f"Normal users can upload videos up to {VIDEO_LIMIT_NORMAL_USER}MB." 20 | ) 21 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/tests/test_signals.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channel_admins.models import ChannelAdmin 3 | 4 | 5 | @pytest.mark.django_db 6 | class TestSignals: 7 | def test_create_admin_after_creating_channel(self, channel): 8 | assert ChannelAdmin.objects.filter(channel=channel).exists() 9 | 10 | def test_delete_admin_after_unsubscribing(self, channel_admin): 11 | assert ChannelAdmin.objects.filter(channel=channel_admin.channel).exists() 12 | channel_admin.channel.subscribers.all().delete() 13 | assert not ChannelAdmin.objects.filter(channel=channel_admin.channel).exists() 14 | 15 | def test_create_permissions_for_admin(self, channel_admin): 16 | assert channel_admin.permissions is not None 17 | assert channel_admin.permissions.admin == channel_admin 18 | -------------------------------------------------------------------------------- /backend/apps/accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from accounts.views.authentication import ( 3 | RegisterUserView, 4 | VerifyUserView, 5 | LoginUserView, 6 | ResendTokenView, 7 | ) 8 | from accounts.views.profile import ProfileView 9 | 10 | app_name = "accounts" 11 | 12 | V1 = [ 13 | path("register/", RegisterUserView.as_view(), name="register"), 14 | path("login/", LoginUserView.as_view(), name="login"), 15 | path( 16 | "verify///", 17 | VerifyUserView.as_view(), 18 | name="verify", 19 | ), 20 | path("resend/", ResendTokenView.as_view(), name="resend_verification"), 21 | path("/", ProfileView.as_view(), name="profile"), 22 | ] 23 | 24 | urlpatterns = [ 25 | path("v1/", include(V1)), 26 | ] 27 | -------------------------------------------------------------------------------- /backend/apps/memberships/migrations/0005_alter_membership_token_alter_subscription_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-15 11:29 2 | 3 | from django.db import migrations, models 4 | import uuid 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("memberships", "0004_alter_membership_token_alter_subscription_token"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="membership", 15 | name="token", 16 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="subscription", 20 | name="token", 21 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/config/settings/test.py: -------------------------------------------------------------------------------- 1 | from .core import * 2 | 3 | from .core import * 4 | 5 | DEBUG = True 6 | ALLOWED_HOSTS = ["*"] 7 | 8 | 9 | DATABASES = { 10 | 'default': { 11 | 'ENGINE': 'django.db.backends.sqlite3', 12 | 'NAME': ':memory:', 13 | } 14 | } 15 | 16 | CACHES = { 17 | "default": { 18 | "BACKEND": "django_redis.cache.RedisCache", 19 | "LOCATION": config("REDIS_URL"), 20 | "OPTIONS": { 21 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 22 | }, 23 | } 24 | } 25 | 26 | PASSWORD_HASHERS = [ 27 | "django.contrib.auth.hashers.MD5PasswordHasher", 28 | ] 29 | 30 | 31 | DOMAIN = "test-domain.com" 32 | 33 | # Disable Throttling 34 | for throttle in REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]: 35 | REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"][throttle] = "1000/second" 36 | -------------------------------------------------------------------------------- /kubernetes/prometheus/deployament.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: tsuna-streaming-prometheus-dp 5 | labels: 6 | app: tsuna-streaming-prometheus 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: tsuna-streaming-prometheus 12 | template: 13 | metadata: 14 | labels: 15 | app: tsuna-streaming-prometheus 16 | spec: 17 | containers: 18 | - name: prometheus 19 | image: prom/prometheus 20 | ports: 21 | - containerPort: 9090 22 | volumeMounts: 23 | - name: prometheus-config 24 | mountPath: /etc/prometheus/prometheus.yml 25 | subPath: prometheus.yml 26 | volumes: 27 | - name: prometheus-config 28 | configMap: 29 | name: prometheus-config -------------------------------------------------------------------------------- /backend/apps/musics/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.conf import settings 3 | from musics.validators import validate_music_size 4 | from contents.models import AbstractContent 5 | from channels.models import Channel 6 | 7 | 8 | class Music(AbstractContent): 9 | """ 10 | Main model for music content. 11 | """ 12 | 13 | user = models.ForeignKey( 14 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="musics" 15 | ) 16 | 17 | channel = models.ForeignKey( 18 | Channel, on_delete=models.CASCADE, related_name="musics" 19 | ) 20 | 21 | def __str__(self) -> str: 22 | return self.title 23 | 24 | def save(self, *args, **kwargs): 25 | # Validate music size 26 | validate_music_size(file=self.file, user=self.user) 27 | return super().save(*args, **kwargs) 28 | -------------------------------------------------------------------------------- /backend/apps/videos/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.conf import settings 3 | from contents.models import AbstractContent 4 | from videos.validators import validate_video_size 5 | from channels.models import Channel 6 | 7 | 8 | class Video(AbstractContent): 9 | """ 10 | Model for saving videos. 11 | """ 12 | 13 | user = models.ForeignKey( 14 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="videos" 15 | ) 16 | 17 | channel = models.ForeignKey( 18 | Channel, on_delete=models.CASCADE, related_name="videos" 19 | ) 20 | 21 | def __str__(self) -> str: 22 | return self.title 23 | 24 | def save(self, *args, **kwargs): 25 | # Validate the video size 26 | validate_video_size(file=self.file, user=self.user) 27 | return super().save(*args, **kwargs) 28 | -------------------------------------------------------------------------------- /backend/apps/votes/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from .views import VoteStatusView, VoteCreateView, VoteDeleteView, VoteListView 3 | 4 | app_name = "votes" 5 | 6 | V1 = [ 7 | path( 8 | "//", 9 | VoteStatusView.as_view(), 10 | name="status", 11 | ), 12 | path( 13 | "//create/", 14 | VoteCreateView.as_view(), 15 | name="create", 16 | ), 17 | path( 18 | "//delete/", 19 | VoteDeleteView.as_view(), 20 | name="delete", 21 | ), 22 | path( 23 | "//list/", 24 | VoteListView.as_view(), 25 | name="list", 26 | ), 27 | ] 28 | 29 | urlpatterns = [ 30 | path("v1/", include(V1)), 31 | ] 32 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0025_alter_account_token_alter_token_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-26 10:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0024_remove_subscription_plan_remove_subscription_user_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="account", 14 | name="token", 15 | field=models.CharField( 16 | blank=True, editable=False, max_length=32, null=True 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="token", 21 | name="token", 22 | field=models.CharField( 23 | blank=True, editable=False, max_length=32, null=True 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /backend/apps/memberships/migrations/0004_alter_membership_token_alter_subscription_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-26 10:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("memberships", "0003_subscription_unique_subscription"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="membership", 14 | name="token", 15 | field=models.CharField( 16 | blank=True, editable=False, max_length=32, null=True 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="subscription", 21 | name="token", 22 | field=models.CharField( 23 | blank=True, editable=False, max_length=32, null=True 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /backend/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest # noqa 2 | import uuid 3 | import time 4 | from apps.core.tests.fixtures import * # noqa 5 | from apps.accounts.tests.fixtures import * # noqa 6 | from apps.memberships.tests.fixtures import * # noqa 7 | from apps.channels.tests.fixtures import * # noqa 8 | from apps.channel_subscribers.tests.fixtures import * # noqa 9 | from apps.channel_admins.tests.fixtures import * # noqa 10 | from apps.votes.tests.fixtures import * # noqa 11 | from apps.videos.tests.fixtures import * # noqa 12 | from apps.viewers.tests.fixtures import * # noqa 13 | from apps.comments.tests.fixtures import * # noqa 14 | from apps.musics.tests.fixtures import * # noqa 15 | 16 | 17 | @pytest.fixture 18 | def create_unique_uuid(): 19 | """Create a unique uuid based on time.""" 20 | time_based_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, str(time.time())) 21 | return time_based_uuid 22 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/managers/test_get_count.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from votes.models import Vote 3 | from channels.models import Channel 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestVoteCount: 8 | def test_get_vote_cached(self, cached_vote): 9 | channel = Channel.objects.get(id=cached_vote["channel"]) 10 | assert ( 11 | Vote.objects.get_count( 12 | channel=channel, content_object=cached_vote["content_object"] 13 | ) 14 | == 1 15 | ) 16 | 17 | def test_get_count_in_db(self, vote): 18 | assert ( 19 | Vote.objects.get_count( 20 | channel=vote.channel, content_object=vote.content_object 21 | ) 22 | == 1 23 | ) 24 | 25 | def test_get_count(self, video): 26 | assert Vote.objects.get_count(channel=video.channel, content_object=video) == 1 27 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from .views import ( 3 | SubscriberStatusView, 4 | SubscriberCreateView, 5 | SubscriberDeleteView, 6 | SubscriberListView, 7 | ) 8 | 9 | app_name = "channel_subscribers" 10 | 11 | V1 = [ 12 | path( 13 | "/", SubscriberStatusView.as_view(), name="subscriber_status" 14 | ), 15 | path( 16 | "/create/", 17 | SubscriberCreateView.as_view(), 18 | name="create_subscriber", 19 | ), 20 | path( 21 | "/delete/", 22 | SubscriberDeleteView.as_view(), 23 | name="delete_subscriber", 24 | ), 25 | path( 26 | "/list/", 27 | SubscriberListView.as_view(), 28 | name="subscriber_list", 29 | ), 30 | ] 31 | 32 | urlpatterns = [ 33 | path("v1/", include(V1)), 34 | ] 35 | -------------------------------------------------------------------------------- /.github/actions/test-docker/action.yaml: -------------------------------------------------------------------------------- 1 | name: "Test On Docker" 2 | description: "Test The Code On Docker" 3 | 4 | inputs: 5 | image_name: 6 | description: "Name of the image" 7 | required: true 8 | default: "tsuna_streaming" 9 | registry: 10 | description: "Docker registry" 11 | required: true 12 | default: "docker.io" 13 | 14 | runs: 15 | using: composite 16 | steps: 17 | - name: SetUp Docker 18 | uses: docker/setup-buildx-action@v2 19 | 20 | - name: Pull The Image 21 | run: docker pull ${{ inputs.registry }}/${{ inputs.image_name }}:latest 22 | shell: bash 23 | 24 | - name: Run Containers 25 | run: make deploy 26 | shell: bash 27 | 28 | - name: Wait for backend container to prepare 29 | uses: jakejarvis/wait-action@master 30 | with: 31 | time: '5s' 32 | 33 | - name: Run tests 34 | run: make test 35 | shell: bash 36 | -------------------------------------------------------------------------------- /backend/apps/accounts/models/role.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from memberships.models import Subscription 3 | 4 | 5 | class AbstractAccountRole(models.Model): 6 | """ 7 | Account roles 8 | Methods: 9 | is_admin)(: Check if user is admin 10 | is_premium(): Check if user is premium (has an active subscription) 11 | is_normal(): Check if user is normal 12 | """ 13 | 14 | class Meta: 15 | abstract = True 16 | 17 | def is_admin(self) -> bool: 18 | """Check if user is admin""" 19 | return self.is_superuser 20 | 21 | def is_premium(self) -> bool: 22 | """Check if user has an active subscription""" 23 | return bool(Subscription.objects.get_active_subscription(user=self)) 24 | 25 | def is_normal(self) -> bool: 26 | """Check to see user is not admin and not premium""" 27 | return not (self.is_admin() or self.is_premium()) 28 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0003_alter_account_bio_alter_account_picture.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-04 08:07 2 | 3 | import accounts.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0002_alter_account_managers"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="account", 15 | name="bio", 16 | field=models.TextField(blank=True, max_length=250), 17 | ), 18 | migrations.AlterField( 19 | model_name="account", 20 | name="picture", 21 | field=models.ImageField( 22 | default="default-user-profile.jpg", 23 | upload_to="accounts/profile", 24 | validators=[accounts.validators.validate_profile_size], 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0019_alter_plan_token_alter_subscription_token_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 17:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0018_merge_20230211_2049"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="plan", 14 | name="token", 15 | field=models.CharField(blank=True, null=True, max_length=32), 16 | ), 17 | migrations.AlterField( 18 | model_name="subscription", 19 | name="token", 20 | field=models.CharField(blank=True, null=True, max_length=32), 21 | ), 22 | migrations.AlterField( 23 | model_name="token", 24 | name="token", 25 | field=models.CharField(blank=True, null=True, max_length=32), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0017_alter_plan_token_alter_subscription_token_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 17:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0016_remove_account_user_id_account_token"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="plan", 14 | name="token", 15 | field=models.CharField(blank=True, max_length=32, null=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="subscription", 19 | name="token", 20 | field=models.CharField(blank=True, max_length=32, null=True), 21 | ), 22 | migrations.AlterField( 23 | model_name="token", 24 | name="token", 25 | field=models.CharField(blank=True, max_length=32, null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0020_alter_plan_token_alter_subscription_token_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-11 18:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0019_alter_plan_token_alter_subscription_token_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="plan", 14 | name="token", 15 | field=models.CharField(blank=True, max_length=32, null=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="subscription", 19 | name="token", 20 | field=models.CharField(blank=True, max_length=32, null=True), 21 | ), 22 | migrations.AlterField( 23 | model_name="token", 24 | name="token", 25 | field=models.CharField(blank=True, max_length=32, null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/apps/viewers/decorators.py: -------------------------------------------------------------------------------- 1 | from viewers.models import Viewer 2 | 3 | 4 | def ensure_viewer_exists(view): 5 | """ 6 | Ensures that a viewer exists for the object, and if not, creates one. 7 | """ 8 | 9 | def ensure_viewer_and_continue(self, request, *args, **kwargs): 10 | # Get the object 11 | target_object = self.get_object() 12 | 13 | # Attempt to fetch an existing viewer for this object 14 | viewer = Viewer.objects.get_from_cache( 15 | channel=target_object.channel, 16 | content_object=target_object, 17 | user=request.user, 18 | ) 19 | 20 | if not viewer: 21 | # If no viewer was found, create one 22 | Viewer.objects.create_in_cache( 23 | channel=target_object.channel, 24 | content_object=target_object, 25 | user=request.user, 26 | ) 27 | 28 | return view(self, request, *args, **kwargs) 29 | 30 | return ensure_viewer_and_continue 31 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.conf import settings 3 | from channels.models import Channel 4 | from channel_subscribers.managers import ChannelSubscriberManager 5 | 6 | 7 | class ChannelSubscriber(models.Model): 8 | """Model to represent a user's subscription to a channel.""" 9 | 10 | channel = models.ForeignKey( 11 | Channel, on_delete=models.CASCADE, related_name="subscribers" 12 | ) 13 | 14 | user = models.ForeignKey( 15 | settings.AUTH_USER_MODEL, 16 | on_delete=models.CASCADE, 17 | related_name="subscribed_channels", 18 | ) 19 | 20 | date = models.DateTimeField(auto_now_add=True) 21 | objects = ChannelSubscriberManager() 22 | 23 | class Meta: 24 | constraints = [ 25 | models.UniqueConstraint( 26 | fields=["channel", "user"], name="unique_channel_subscribers" 27 | ) 28 | ] 29 | 30 | def __str__(self) -> str: 31 | return f"{self.user} subscribed to {self.channel}" 32 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | from channel_subscribers.models import ChannelSubscriber 3 | 4 | 5 | class CanSubscribePermission(BasePermission): 6 | """ 7 | Permission to check if the user is already subscribed to the channel. 8 | """ 9 | 10 | message = "You are already subscribed to this channel." 11 | 12 | def has_object_permission(self, request, view, obj): 13 | subscribed = ChannelSubscriber.objects.get_from_cache( 14 | user=request.user, channel=obj 15 | ) 16 | return not bool(subscribed) 17 | 18 | 19 | class CanUnSubscribePermission(BasePermission): 20 | """ 21 | Permission to see if user subscribed a channel of not 22 | """ 23 | 24 | message = "You havnt subscribed yet." 25 | 26 | def has_object_permission(self, request, view, obj): 27 | subscribed = ChannelSubscriber.objects.get_from_cache( 28 | user=request.user, channel=obj 29 | ) 30 | return bool(subscribed) 31 | -------------------------------------------------------------------------------- /backend/apps/memberships/migrations/0006_rename_finish_date_subscription_end_date_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-18 09:00 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("memberships", "0005_alter_membership_token_alter_subscription_token"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameField( 16 | model_name="subscription", 17 | old_name="finish_date", 18 | new_name="end_date", 19 | ), 20 | migrations.AlterField( 21 | model_name="subscription", 22 | name="user", 23 | field=models.ForeignKey( 24 | on_delete=django.db.models.deletion.CASCADE, 25 | related_name="subscriptions", 26 | to=settings.AUTH_USER_MODEL, 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /backend/apps/musics/migrations/0004_alter_music_file_alter_music_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-04 09:31 2 | 3 | import contents.models.utils.file_path 4 | import contents.models.utils.thumbnail_path 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("musics", "0003_remove_music_music_music_file_alter_music_thumbnail"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="music", 16 | name="file", 17 | field=models.FileField( 18 | upload_to=contents.models.utils.file_path.get_file_upload_path 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="music", 23 | name="thumbnail", 24 | field=models.ImageField( 25 | default="assets/images/default-thumbnail.jpg", 26 | upload_to=contents.models.utils.thumbnail_path.get_thumbnail_upload_path, 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0010_alter_video_file_alter_video_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-04 09:31 2 | 3 | import contents.models.utils.file_path 4 | import contents.models.utils.thumbnail_path 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("videos", "0009_remove_video_video_video_file_alter_video_thumbnail"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="video", 16 | name="file", 17 | field=models.FileField( 18 | upload_to=contents.models.utils.file_path.get_file_upload_path 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="video", 23 | name="thumbnail", 24 | field=models.ImageField( 25 | default="assets/images/default-thumbnail.jpg", 26 | upload_to=contents.models.utils.thumbnail_path.get_thumbnail_upload_path, 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /backend/apps/channels/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | 3 | 4 | class IsChannelStaff(BasePermission): 5 | """ 6 | A permission for channel Viewset 7 | Delete -> Only channel owner can delete the channel. 8 | Update -> Only admins with change_channel_info permission can update the channel. 9 | """ 10 | 11 | message = "Only channel admins can perform this action." 12 | 13 | def has_object_permission(self, request, view, obj): 14 | # Only channel owner can delete the channel. 15 | if request.method == "DELETE": 16 | return request.user == obj.owner 17 | 18 | # check permission for updating channel 19 | elif request.method in ["PUT", "PATCH"]: 20 | # Get the admin object 21 | channel_admin = request.user.channel_admins.filter(channel=obj).first() 22 | # Check if admin has permission to change channel info 23 | return channel_admin and channel_admin.permissions.can_change_channel_info 24 | 25 | # If request is GET 26 | return True 27 | -------------------------------------------------------------------------------- /backend/apps/channels/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save 3 | from apps.core.tasks import send_email 4 | from channels.models import Channel 5 | from channel_subscribers.signals import create_subscriber_after_creating_channel 6 | from channel_admins.signals import create_admin_after_creating_channel 7 | 8 | 9 | post_save.connect(create_subscriber_after_creating_channel, sender=Channel) 10 | post_save.connect(create_admin_after_creating_channel, sender=Channel) 11 | 12 | 13 | @receiver(post_save, sender=Channel) 14 | def notify_channel_creation(sender, instance, created, **kwargs): 15 | """ 16 | Notify channel owner after creating channel 17 | """ 18 | user = instance.owner 19 | 20 | if created: 21 | send_email( 22 | template_name="emails/notify_creation.html", 23 | to_email=user.email, 24 | body={ 25 | "first_name": user.first_name, 26 | "channel_title": instance.title, 27 | "channel_token": instance.token, 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /backend/apps/contents/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from contents.models import AbstractContent 3 | from core.utils import get_content_type_model 4 | from viewers.models import Viewer 5 | 6 | 7 | class ContentDetailMethodSerializer(serializers.Serializer): 8 | """ 9 | Provide necessary fields for content detail serializers. 10 | """ 11 | 12 | content_type_id = serializers.SerializerMethodField( 13 | method_name="get_content_type_id", read_only=True 14 | ) 15 | viewers_count = serializers.SerializerMethodField( 16 | method_name="get_viewers_count", read_only=True 17 | ) 18 | 19 | def get_content_type_id(self, obj: AbstractContent) -> int: 20 | """ 21 | Return content type id for a content object. 22 | """ 23 | return get_content_type_model(model=type(obj)).id 24 | 25 | def get_viewers_count(self, obj) -> int: 26 | """ 27 | Return Viewer count for a content object. 28 | """ 29 | return Viewer.objects.get_count( 30 | content_object=obj, 31 | channel=obj.channel, 32 | ) 33 | -------------------------------------------------------------------------------- /backend/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mohamad Liyaghi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/tests/models/test_admin_permission.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db.utils import IntegrityError 3 | from channel_admins.models import ChannelAdmin, ChannelAdminPermission 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestAdminPermissionModel: 8 | def test_channel_owner_permission(self, channel): 9 | permission = channel.owner.channel_admins.first().permissions 10 | assert permission.can_add_object is True 11 | assert permission.can_edit_object is True 12 | assert permission.can_delete_object is True 13 | assert permission.can_publish_object is True 14 | 15 | def test_channel_admin_permission(self, channel_admin): 16 | permission = channel_admin.permissions 17 | assert permission.can_add_object is False 18 | assert permission.can_edit_object is False 19 | assert permission.can_delete_object is False 20 | assert permission.can_publish_object is False 21 | 22 | def test_create_permission_twice(self, channel_admin): 23 | with pytest.raises(IntegrityError): 24 | ChannelAdminPermission.objects.create(admin=channel_admin) 25 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0009_alter_channel_profile_alter_channel_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-02-10 09:40 2 | 3 | import accounts.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("channels", "0008_channeladmin_block_user"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="channel", 15 | name="profile", 16 | field=models.ImageField( 17 | default="assets/images/default-channel-profile.jpg", 18 | upload_to="channels/profile", 19 | validators=[accounts.validators.validate_profile_size], 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="channel", 24 | name="thumbnail", 25 | field=models.ImageField( 26 | default="assets/images/default-thumbnail.jpg", 27 | upload_to="channels/profile", 28 | validators=[accounts.validators.validate_profile_size], 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0020_alter_channel_avatar_alter_channel_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-06 10:15 2 | 3 | import accounts.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("channels", "0019_rename_profile_channel_avatar_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="channel", 15 | name="avatar", 16 | field=models.ImageField( 17 | default="static/images/default-channel-profile.jpg", 18 | upload_to="channels/profile", 19 | validators=[accounts.validators.validate_profile_size], 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="channel", 24 | name="thumbnail", 25 | field=models.ImageField( 26 | default="static/images/default-thumbnail.jpg", 27 | upload_to="channels/profile", 28 | validators=[accounts.validators.validate_profile_size], 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /backend/apps/channels/tests/views/test_list.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestChannelList: 8 | def setup(self): 9 | self.url = reverse("channels:list-create") 10 | 11 | def test_get_unauthorized(self, api_client): 12 | response = api_client.get(self.url) 13 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 14 | 15 | def test_get_by_superuser(self, api_client, superuser): 16 | api_client.force_authenticate(user=superuser) 17 | response = api_client.get(self.url) 18 | assert response.status_code == status.HTTP_200_OK 19 | 20 | def test_get_by_normal_user(self, api_client, user): 21 | api_client.force_authenticate(user=user) 22 | response = api_client.get(self.url) 23 | assert response.status_code == status.HTTP_200_OK 24 | 25 | def test_get_by_premium_user(self, api_client, premium_user): 26 | api_client.force_authenticate(user=premium_user) 27 | response = api_client.get(self.url) 28 | assert response.status_code == status.HTTP_200_OK 29 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0021_alter_channel_avatar_alter_channel_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-08-06 10:19 2 | 3 | import accounts.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("channels", "0020_alter_channel_avatar_alter_channel_thumbnail"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="channel", 15 | name="avatar", 16 | field=models.ImageField( 17 | default="assets/images/default-channel-profile.jpg", 18 | upload_to="channels/profile", 19 | validators=[accounts.validators.validate_profile_size], 20 | ), 21 | ), 22 | migrations.AlterField( 23 | model_name="channel", 24 | name="thumbnail", 25 | field=models.ImageField( 26 | default="assets/images/default-thumbnail.jpg", 27 | upload_to="channels/profile", 28 | validators=[accounts.validators.validate_profile_size], 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /backend/apps/viewers/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import IsAuthenticated 2 | from rest_framework.generics import ListAPIView 3 | 4 | from drf_spectacular.utils import extend_schema, extend_schema_view 5 | 6 | from viewers.serializers import ViewerListSerializer 7 | from viewers.models import Viewer 8 | from core.mixins import ContentObjectMixin 9 | 10 | 11 | @extend_schema_view( 12 | get=extend_schema( 13 | description="List of an objects viewers.", 14 | responses={ 15 | 200: "ok", 16 | 401: "Unauthorized", 17 | 404: "Not found", 18 | }, 19 | tags=["Viewers"], 20 | ), 21 | ) 22 | class ViewerListView(ContentObjectMixin, ListAPIView): 23 | """ 24 | Return a list of viewers for an object. 25 | """ 26 | 27 | permission_classes = [IsAuthenticated] 28 | serializer_class = ViewerListSerializer 29 | 30 | def get_queryset(self): 31 | content_object = self.get_object() 32 | # Get the viewers from cache and db and return 33 | return Viewer.objects.get_list( 34 | channel=content_object.channel, content_object=content_object 35 | ) 36 | -------------------------------------------------------------------------------- /.github/actions/test-kubernetes/action.yaml: -------------------------------------------------------------------------------- 1 | name: Test On Kubernetes 2 | description: "Test the application on kubernetes" 3 | 4 | runs: 5 | using: 'composite' 6 | steps: 7 | - uses: debianmaster/actions-k3s@master 8 | id: k3s 9 | with: 10 | version: 'latest' 11 | 12 | - name: Create ConfigMap 13 | run: make local_confmap 14 | shell: bash 15 | 16 | - name: Run Kubernetes Deployments 17 | run: make k8s 18 | shell: bash 19 | 20 | - name: Wait for Test Database Deployments 21 | run: kubectl wait --for=condition=available --timeout=60s deployment/tsuna-streaming-postgres-deployment 22 | shell: bash 23 | 24 | - name: Wait for Redis Service 25 | run: kubectl wait --for=condition=available --timeout=60s deployment/tsuna-streaming-redis-deployment 26 | shell: bash 27 | 28 | - name: Wait for Backend Deployments 29 | run: kubectl wait --for=condition=available --timeout=60s deployment/tsuna-streaming-backend-deployment 30 | shell: bash 31 | 32 | - name: Run Backend Tests 33 | run: kubectl exec -it $(kubectl get pods | grep backend | awk '{print $1}') -- pytest 34 | shell: bash -------------------------------------------------------------------------------- /backend/apps/memberships/tests/views/test_list.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestMembershipListView: 8 | def setup(self): 9 | self.url = reverse("memberships:membership") 10 | 11 | def test_get_unauthorized_fails(self, api_client): 12 | response = api_client.get(self.url) 13 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 14 | 15 | def test_get_by_superuser(self, superuser, api_client): 16 | api_client.force_authenticate(user=superuser) 17 | response = api_client.get(self.url) 18 | assert response.status_code == status.HTTP_200_OK 19 | 20 | def test_get_by_premium_user(self, premium_user, api_client): 21 | api_client.force_authenticate(user=premium_user) 22 | response = api_client.get(self.url) 23 | assert response.status_code == status.HTTP_200_OK 24 | 25 | def test_get_by_normal_user(self, user, api_client): 26 | api_client.force_authenticate(user=user) 27 | response = api_client.get(self.url) 28 | assert response.status_code == status.HTTP_200_OK 29 | -------------------------------------------------------------------------------- /backend/apps/channels/migrations/0015_alter_channelsubscriber_channel_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-03-16 16:34 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("channels", "0014_delete_channeladmin"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="channelsubscriber", 17 | name="channel", 18 | field=models.ForeignKey( 19 | on_delete=django.db.models.deletion.CASCADE, 20 | related_name="subscriber", 21 | to="channels.channel", 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="channelsubscriber", 26 | name="user", 27 | field=models.ForeignKey( 28 | on_delete=django.db.models.deletion.CASCADE, 29 | related_name="subscribed_channel", 30 | to=settings.AUTH_USER_MODEL, 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /backend/apps/accounts/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save 3 | from django.conf import settings 4 | 5 | from accounts.models import VerificationToken 6 | from apps.core.tasks import send_email 7 | 8 | 9 | @receiver(post_save, sender=settings.AUTH_USER_MODEL) 10 | def create_verification_token(sender, created, instance, **kwargs): 11 | """Create token for user when user created.""" 12 | # Check user is not active 13 | if created and not instance.is_active: 14 | VerificationToken.objects.create(user=instance) 15 | 16 | 17 | @receiver(post_save, sender=VerificationToken) 18 | def send_token_via_email(sender, created, **kwargs): 19 | """ 20 | Send token via email when token created. 21 | """ 22 | 23 | if created: 24 | token = kwargs["instance"] 25 | user = token.user 26 | 27 | send_email.delay( 28 | template_name="emails/verification_token.html", 29 | to_email=user.email, 30 | body={ 31 | "first_name": user.first_name, 32 | "user_token": user.token, 33 | "token": token.token, 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/managers/test_create_in_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from votes.models import Vote 3 | 4 | 5 | @pytest.mark.django_db 6 | class TestCreateInCache: 7 | def test_create_cached_vote(self, channel, superuser, video): 8 | Vote.objects.create_in_cache( 9 | channel=channel, 10 | user=superuser, 11 | content_object=video, 12 | ) 13 | assert ( 14 | Vote.objects.get_count( 15 | channel=channel, 16 | content_object=video, 17 | ) 18 | == 1 19 | ) 20 | 21 | def test_create_cached_vote_twice(self, channel, superuser, video): 22 | Vote.objects.create_in_cache( 23 | channel=channel, 24 | user=superuser, 25 | content_object=video, 26 | ) 27 | # Second Time 28 | Vote.objects.create_in_cache( 29 | channel=channel, 30 | user=superuser, 31 | content_object=video, 32 | ) 33 | assert ( 34 | Vote.objects.get_count( 35 | channel=channel, 36 | content_object=video, 37 | ) 38 | == 1 39 | ) 40 | -------------------------------------------------------------------------------- /backend/apps/core/utils/cache_key.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from typing import Optional, Union 4 | from channels.models import Channel 5 | 6 | 7 | def generate_cache_key( 8 | key: str, 9 | channel: Channel, 10 | user: Union[settings.AUTH_USER_MODEL, str] = "*", 11 | content_object: Optional[models.Model] = None, 12 | ) -> str: 13 | """ 14 | Generate a cache key based on the given parameters. 15 | 16 | :param key: The base formattable string for the cache key. 17 | :param channel: The channel instance. 18 | :param user: The user instance or a string represent all (*). 19 | :param content_object: An optional content object instance. 20 | 21 | :return: The generated cache key. 22 | """ 23 | 24 | # Use the user's token if available, otherwise use the user string 25 | user_token = user.token if hasattr(user, "token") else user 26 | 27 | if content_object: 28 | return key.format( 29 | channel_token=channel.token, 30 | user_token=user_token, 31 | object_token=content_object.token, 32 | ) 33 | 34 | return key.format(channel_token=channel.token, user_token=user_token) 35 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0008_plan.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-11 08:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("accounts", "0006_alter_token_options"), 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Plan", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("title", models.CharField(max_length=210)), 25 | ("description", models.TextField(max_length=400)), 26 | ("price", models.PositiveBigIntegerField(default=0)), 27 | ("token", models.CharField(blank=True, null=True, max_length=32)), 28 | ("active_months", models.PositiveBigIntegerField(default=0)), 29 | ("is_avaiable", models.BooleanField(default=False)), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /backend/requirement.txt: -------------------------------------------------------------------------------- 1 | amqp==5.1.1 2 | asgiref==3.6.0 3 | async-timeout==4.0.2 4 | attrs==22.2.0 5 | billiard==3.6.4.0 6 | cached-property==1.2.0 7 | celery==5.2.7 8 | click==8.1.3 9 | click-didyoumean==0.3.0 10 | click-plugins==1.1.1 11 | click-repl==0.2.0 12 | colorama==0.4.6 13 | Django==4.1.4 14 | django-cleanup==7.0.0 15 | django-filter==22.1 16 | django-redis==5.2.0 17 | django-templated-mail==1.1.1 18 | django_debug_toolbar==3.8.1 19 | djangorestframework==3.14.0 20 | djangorestframework-simplejwt==5.2.2 21 | drf-spectacular==0.25.1 22 | exceptiongroup==1.1.0 23 | flower==1.2.0 24 | Faker==18.11.1 25 | gunicorn==20.1.0 26 | humanize==4.6.0 27 | inflection==0.5.1 28 | iniconfig==1.1.1 29 | jsonschema==4.17.3 30 | kombu==5.2.4 31 | Markdown==3.4.1 32 | mock==5.1.0 33 | packaging==22.0 34 | Pillow==9.4.0 35 | pluggy==1.0.0 36 | prometheus-client==0.16.0 37 | prompt-toolkit==3.0.36 38 | psycopg2-binary==2.9.5 39 | PyJWT==2.6.0 40 | pyrsistent==0.19.3 41 | pytest==7.4.0 42 | pytest-django==4.5.2 43 | pytest-env==0.8.1 44 | python-decouple==3.6 45 | pytz==2022.7 46 | redis==4.4.0 47 | six==1.16.0 48 | sqlparse==0.4.3 49 | tomli==2.0.1 50 | tornado==6.2 51 | tzdata==2022.7 52 | uritemplate==4.1.1 53 | vine==5.0.0 54 | wcwidth==0.2.5 55 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/views/test_list.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from rest_framework import status 3 | import pytest 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestSubscriberListView: 8 | def setup(self): 9 | self.url_name = "channel_subscribers:subscriber_list" 10 | 11 | def test_unauthorized(self, channel, api_client): 12 | response = api_client.get( 13 | reverse(self.url_name, kwargs={"channel_token": channel.token}) 14 | ) 15 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 16 | 17 | def test_not_found(self, api_client, create_unique_uuid): 18 | response = api_client.get( 19 | reverse(self.url_name, kwargs={"channel_token": create_unique_uuid}) 20 | ) 21 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 22 | 23 | def test_get_list(self, subscriber, api_client): 24 | api_client.force_authenticate(user=subscriber.user) 25 | response = api_client.get( 26 | reverse(self.url_name, kwargs={"channel_token": subscriber.channel.token}) 27 | ) 28 | assert response.status_code == status.HTTP_200_OK 29 | assert response.data["count"] == 2 30 | -------------------------------------------------------------------------------- /backend/apps/votes/migrations/0002_vote_channel_vote_date_vote_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-28 08:41 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("channels", "0019_rename_profile_channel_avatar_and_more"), 12 | ("votes", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="vote", 18 | name="channel", 19 | field=models.ForeignKey( 20 | blank=True, 21 | null=True, 22 | on_delete=django.db.models.deletion.CASCADE, 23 | to="channels.channel", 24 | ), 25 | ), 26 | migrations.AddField( 27 | model_name="vote", 28 | name="date", 29 | field=models.DateTimeField(default=django.utils.timezone.now), 30 | ), 31 | migrations.AddField( 32 | model_name="vote", 33 | name="token", 34 | field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /backend/apps/votes/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | from votes.models import Vote 3 | 4 | 5 | class CanCreateVotePermission(BasePermission): 6 | """ 7 | Check if the user can vote or not. 8 | """ 9 | 10 | message = "You have already voted to this object." 11 | 12 | def has_object_permission(self, request, view, obj): 13 | """ 14 | Check if user has not voted yet 15 | """ 16 | vote_exists = bool( 17 | Vote.objects.get_from_cache( 18 | channel=obj.channel, user=request.user, content_object=obj 19 | ) 20 | ) 21 | return not vote_exists 22 | 23 | 24 | class CanDeleteVotePermission(BasePermission): 25 | """ 26 | Check if the user can delete vote or not. 27 | """ 28 | 29 | message = "You have not voted to this object." 30 | 31 | def has_object_permission(self, request, view, obj): 32 | """ 33 | Check if user has voted 34 | """ 35 | vote_exists = bool( 36 | Vote.objects.get_from_cache( 37 | channel=obj.channel, user=request.user, content_object=obj 38 | ) 39 | ) 40 | return vote_exists 41 | -------------------------------------------------------------------------------- /backend/apps/viewers/tests/managers/test_create_in_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from viewers.models import Viewer 3 | 4 | 5 | @pytest.mark.django_db 6 | class TestCreateInCache: 7 | def test_create_cached_viewer(self, channel, superuser, video): 8 | Viewer.objects.create_in_cache( 9 | channel=channel, 10 | user=superuser, 11 | content_object=video, 12 | ) 13 | assert ( 14 | Viewer.objects.get_count( 15 | channel=channel, 16 | content_object=video, 17 | ) 18 | == 1 19 | ) 20 | 21 | def test_create_cached_viewer_twice(self, channel, superuser, video): 22 | Viewer.objects.create_in_cache( 23 | channel=channel, 24 | user=superuser, 25 | content_object=video, 26 | ) 27 | # Second Time 28 | Viewer.objects.create_in_cache( 29 | channel=channel, 30 | user=superuser, 31 | content_object=video, 32 | ) 33 | assert ( 34 | Viewer.objects.get_count( 35 | channel=channel, 36 | content_object=video, 37 | ) 38 | == 1 39 | ) 40 | -------------------------------------------------------------------------------- /backend/apps/votes/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from votes.models import Vote 3 | 4 | 5 | class VoteStatusSerializer(serializers.ModelSerializer): 6 | """ 7 | Show a users vote status 8 | """ 9 | 10 | class Meta: 11 | model = Vote 12 | fields = ["date", "choice"] 13 | 14 | 15 | class VoteCreateSerializer(serializers.ModelSerializer): 16 | """ 17 | A serializer for creating a vote 18 | """ 19 | 20 | class Meta: 21 | model = Vote 22 | fields = ["choice"] 23 | 24 | def create(self, validated_data): 25 | content_object = self.context["content_object"]() 26 | user = self.context["user"] 27 | 28 | # Create a vote in cache 29 | return Vote.objects.create_in_cache( 30 | channel=content_object.channel, 31 | user=user, 32 | content_object=content_object, 33 | **validated_data 34 | ) 35 | 36 | 37 | class VoteListSerializer(serializers.ModelSerializer): 38 | """ 39 | List of all votes in cache 40 | """ 41 | 42 | user = serializers.StringRelatedField() 43 | 44 | class Meta: 45 | model = Vote 46 | fields = ["user", "choice", "date"] 47 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/test_model.py: -------------------------------------------------------------------------------- 1 | from django.db.utils import IntegrityError 2 | import pytest 3 | from votes.models import Vote 4 | from channels.models import Channel 5 | 6 | 7 | @pytest.mark.django_db 8 | class TestVoteModel: 9 | def test_set_channel_for_vote(self, vote): 10 | assert vote.channel == vote.content_object.channel 11 | 12 | def test_vote_token(self, vote): 13 | assert vote.token 14 | 15 | def test_vote_uniqueness(self, vote): 16 | """ 17 | Users can only vote to an object once 18 | """ 19 | with pytest.raises(IntegrityError): 20 | Vote.objects.create(user=vote.user, content_object=vote.content_object) 21 | 22 | def test_get_object_votes_count_no_vote(self, video): 23 | """ 24 | Get count of an objects votes. 25 | """ 26 | 27 | votes_count = Vote.objects.get_count( 28 | content_object=video.channel, channel=video.channel 29 | ) 30 | assert votes_count == 0 31 | 32 | def test_get_object_votes_count_vote_in_db(self, video, vote): 33 | votes_count = Vote.objects.get_count( 34 | content_object=video, channel=video.channel 35 | ) 36 | assert votes_count == 1 37 | -------------------------------------------------------------------------------- /kubernetes/postgres/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: tsuna-streaming-postgres-deployment 5 | labels: 6 | app: tsuna-streaming-postgres 7 | 8 | spec: 9 | replicas: 1 10 | selector: 11 | matchLabels: 12 | app: tsuna-streaming-postgres 13 | template: 14 | metadata: 15 | labels: 16 | app: tsuna-streaming-postgres 17 | spec: 18 | containers: 19 | - name: postgres 20 | image: postgres:11 21 | ports: 22 | - containerPort: 5432 23 | envFrom: 24 | - configMapRef: 25 | name: tsuna-streaming-env 26 | env: 27 | - name: POSTGRES_USER 28 | value: "$(DATABASE_USER)" 29 | - name: POSTGRES_PASSWORD 30 | value: "$(DATABASE_PASSWORD)" 31 | - name: POSTGRES_DB 32 | value: "$(DATABASE_NAME)" 33 | 34 | volumeMounts: 35 | - mountPath: /var/lib/postgresql/data 36 | name: postgres-volume 37 | volumes: 38 | - name: postgres-volume 39 | emptyDir: {} -------------------------------------------------------------------------------- /backend/apps/musics/migrations/0003_remove_music_music_music_file_alter_music_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-15 13:52 2 | 3 | import apps.contents.models.utils.file_path 4 | import apps.contents.models.utils.thumbnail_path 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("musics", "0002_alter_music_token"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name="music", 16 | name="music", 17 | ), 18 | migrations.AddField( 19 | model_name="music", 20 | name="file", 21 | field=models.FileField( 22 | default="ff", 23 | upload_to=apps.contents.models.utils.file_path.get_file_upload_path, 24 | ), 25 | preserve_default=False, 26 | ), 27 | migrations.AlterField( 28 | model_name="music", 29 | name="thumbnail", 30 | field=models.ImageField( 31 | default="assets/images/default-thumbnail.jpg", 32 | upload_to=apps.contents.models.utils.thumbnail_path.get_thumbnail_upload_path, 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /backend/apps/videos/migrations/0009_remove_video_video_video_file_alter_video_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-07-15 13:52 2 | 3 | import apps.contents.models.utils.file_path 4 | import apps.contents.models.utils.thumbnail_path 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("videos", "0008_alter_video_token"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name="video", 16 | name="video", 17 | ), 18 | migrations.AddField( 19 | model_name="video", 20 | name="file", 21 | field=models.FileField( 22 | default="22", 23 | upload_to=apps.contents.models.utils.file_path.get_file_upload_path, 24 | ), 25 | preserve_default=False, 26 | ), 27 | migrations.AlterField( 28 | model_name="video", 29 | name="thumbnail", 30 | field=models.ImageField( 31 | default="assets/images/default-thumbnail.jpg", 32 | upload_to=apps.contents.models.utils.thumbnail_path.get_thumbnail_upload_path, 33 | ), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /backend/apps/viewers/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.conf import settings 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.contrib.contenttypes.fields import GenericForeignKey 5 | from viewers.managers import ViewerManager 6 | from channels.models import Channel 7 | 8 | 9 | class Viewer(models.Model): 10 | """ 11 | Model for tracking views of any object. 12 | """ 13 | 14 | user = models.ForeignKey( 15 | settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True 16 | ) 17 | channel = models.ForeignKey(Channel, on_delete=models.CASCADE, null=True) 18 | date = models.DateTimeField(auto_now_add=True) 19 | 20 | content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) 21 | object_id = models.PositiveIntegerField() 22 | content_object = GenericForeignKey("content_type", "object_id") 23 | 24 | objects = ViewerManager() 25 | 26 | def __str__(self) -> str: 27 | return str(self.user) 28 | 29 | def save(self, *args, **kwargs): 30 | if not self.pk: 31 | self.__set_channel() 32 | super().save(*args, **kwargs) 33 | 34 | def __set_channel(self) -> None: 35 | self.channel = self.content_object.channel 36 | -------------------------------------------------------------------------------- /backend/apps/votes/tests/managers/test_delete_in_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channels.models import Channel 3 | from accounts.models import Account 4 | from votes.models import Vote 5 | 6 | 7 | @pytest.mark.django_db 8 | class TestDeleteInCache: 9 | def test_delete_db_vote_in_cache(self, vote): 10 | channel = vote.channel 11 | 12 | Vote.objects.delete_in_cache( 13 | channel=channel, 14 | user=vote.user, 15 | content_object=vote.content_object, 16 | ) 17 | assert ( 18 | Vote.objects.get_count( 19 | channel=channel, 20 | content_object=vote.content_object, 21 | ) 22 | == 0 23 | ) 24 | 25 | def test_delete_cached_vote(self, cached_vote): 26 | channel = Channel.objects.get(id=cached_vote["channel"]) 27 | user = Account.objects.get(id=cached_vote["user"]) 28 | content_object = cached_vote["content_object"] 29 | 30 | Vote.objects.delete_in_cache( 31 | channel=channel, 32 | user=user, 33 | content_object=content_object, 34 | ) 35 | assert Vote.objects.get_count( 36 | channel=channel, 37 | content_object=content_object, 38 | ) 39 | -------------------------------------------------------------------------------- /backend/apps/comments/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from comments.models import Comment 2 | from comments.exceptions import CommentNotAllowed 3 | import pytest 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestCommentModel: 8 | def test_update_comment(self, comment, video): 9 | """ 10 | When a comment is updated, edited field should be set to True 11 | """ 12 | 13 | assert not comment.edited 14 | 15 | comment.body = "Updated body" 16 | comment.save() 17 | comment.refresh_from_db() 18 | 19 | assert comment.edited 20 | 21 | def test_raise_exception_comment_not_allowed(self, video): 22 | """ 23 | Raise exception when comments are not allowed for object 24 | and user tries to create a comment 25 | """ 26 | 27 | video.allow_comment = False 28 | video.save() 29 | 30 | with pytest.raises(CommentNotAllowed): 31 | Comment.objects.create(content_object=video, user=video.user, body="test") 32 | 33 | def test_delete_comments_after_deleting_video(self, comment, video): 34 | """ 35 | Ensure that comments are deleted after deleting the video 36 | """ 37 | video_id = video.id 38 | video.delete() 39 | assert Comment.objects.filter(object_id=video_id).count() == 0 40 | -------------------------------------------------------------------------------- /backend/.env.local: -------------------------------------------------------------------------------- 1 | # Secret Key 2 | SECRET_KEY='django-insecure--2(h+7swts+m-7vo$7dunu-hiuhh5r6b-ttv@x@q_@4iu1zc@f' 3 | 4 | # Database Config 5 | DATABASE_NAME=tsuna_streaming_local 6 | DATABASE_USER=postgres 7 | DATABASE_PASSWORD=postgres 8 | DATABASE_HOST=postgres 9 | DATABASE_PORT=5432 10 | 11 | # Postgres Config 12 | POSTGRES_DB=${DATABASE_NAME} 13 | POSTGRES_USER=${DATABASE_USER} 14 | POSTGRES_PASSWORD=${DATABASE_PASSWORD} 15 | 16 | # Email SMTP server config 17 | EMAIL_HOST='smtp4dev' 18 | EMAIL_USER='' 19 | EMAIL_PASSWORD='' 20 | EMAIL_PORT=25 21 | EMAIL_FROM='contact@tsuna-streaming.com' 22 | 23 | # Celery Config 24 | CELERY_BROKER=redis://redis:6379/1 25 | 26 | # Redis cache db 27 | REDIS_URL=redis://redis:6379/2 28 | 29 | LOCAL_DOMAIN="http://127.0.0.1:8000/" 30 | 31 | # Cache Keys 32 | # Key of a subscriber in channel 33 | CACHE_CHANNEL_SUBSCRIBER='subscriber:{channel_token}:{user_token}' 34 | CACHE_OBJECT_VOTE='vote:{channel_token}:{object_token}:{user_token}' 35 | CACHE_OBJECT_VIEWER='viewer:{channel_token}:{object_token}:{user_token}' 36 | CACHE_CONTENT_TYPE_KEY='content_type:{model}:{id}' 37 | 38 | # File Sizes 39 | VIDEO_LIMIT_NORMAL_USER=50 40 | VIDEO_LIMIT_PREMIUM_USER=100 41 | MUSIC_LIMIT_NORMAL_USER=10 42 | MUSIC_LIMIT_PREMIUM_USER=20 43 | 44 | # PG Admin 45 | PGADMIN_DEFAULT_EMAIL=test@test.com 46 | PGADMIN_DEFAULT_PASSWORD=test -------------------------------------------------------------------------------- /backend/apps/musics/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save, post_delete 3 | from apps.core.tasks import send_email 4 | from musics.models import Music 5 | 6 | 7 | @receiver(post_save, sender=Music) 8 | def notify_music_creation(sender, instance, created, **kwargs): 9 | if created: 10 | data = { 11 | "first_name": instance.user.first_name, 12 | "channel_title": instance.title, 13 | "channel_token": instance.channel.token, 14 | "music_token": instance.token, 15 | } 16 | 17 | if instance.user == instance.channel.owner: 18 | send_email.delay( 19 | template_name="emails/notify_music_creation.html", 20 | to_email=instance.user.email, 21 | body=data, 22 | ) 23 | 24 | else: 25 | # send email to both admin and owner 26 | send_email.delay( 27 | template_name="emails/notify_music_creation.html", 28 | to_email=instance.user.email, 29 | body=data, 30 | ) 31 | 32 | send_email.delay( 33 | template_name="emails/notify_music_creation.html", 34 | to_email=instance.channel.owner.email, 35 | body=data, 36 | ) 37 | -------------------------------------------------------------------------------- /kubernetes/celery/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: tsuna-streaming-celery-deployment 5 | namespace: default 6 | 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: tsuna-streaming-celery 12 | 13 | template: 14 | metadata: 15 | labels: 16 | app: tsuna-streaming-celery 17 | spec: 18 | containers: 19 | - name: celery 20 | image: ml06py/tsuna_streaming 21 | command: ["/bin/sh"] 22 | args: ["/backend/docker/commands/celery.sh"] 23 | 24 | envFrom: 25 | - configMapRef: 26 | name: tsuna-streaming-env 27 | 28 | env: 29 | - name: DJANGO_SETTINGS_MODULE 30 | value: "config.settings.local" 31 | - name: ENVIRONMENT 32 | value: "LOCAL" 33 | 34 | - name: celery-beat 35 | image: ml06py/tsuna_streaming 36 | command: ["/bin/sh"] 37 | args: ["/backend/docker/commands/celery-beat.sh"] 38 | 39 | envFrom: 40 | - configMapRef: 41 | name: tsuna-streaming-env 42 | 43 | env: 44 | - name: DJANGO_SETTINGS_MODULE 45 | value: "config.settings.local" 46 | - name: ENVIRONMENT 47 | value: "LOCAL" 48 | 49 | -------------------------------------------------------------------------------- /kubernetes/backend/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | 4 | metadata: 5 | name: tsuna-streaming-backend-deployment 6 | labels: 7 | app: tsuna-streaming-backend 8 | 9 | spec: 10 | replicas: 1 11 | selector: 12 | matchLabels: 13 | app: tsuna-streaming-backend 14 | template: 15 | metadata: 16 | labels: 17 | app: tsuna-streaming-backend 18 | spec: 19 | containers: 20 | - name: backend 21 | image: ml06py/tsuna_streaming 22 | command: ['/bin/sh'] 23 | args: ["/backend/docker/commands/setup.sh"] 24 | 25 | volumeMounts: 26 | - name: env-file 27 | mountPath: /backend/.env 28 | subPath: .env 29 | envFrom: 30 | - configMapRef: 31 | name: tsuna-streaming-env 32 | env: 33 | - name: DJANGO_SETTINGS_MODULE 34 | value: "config.settings.local" 35 | - name: ENVIRONMENT 36 | value: "LOCAL" 37 | - name: DATABASE_PORT 38 | value: "5432" 39 | 40 | ports: 41 | - containerPort: 8000 42 | 43 | volumes: 44 | - name: env-file 45 | configMap: 46 | name: tsuna-streaming-env-file 47 | items: 48 | - key: .env 49 | path: .env 50 | -------------------------------------------------------------------------------- /backend/.env.prod: -------------------------------------------------------------------------------- 1 | # Secret Key 2 | SECRET_KEY='django-insecure--2(h+7swts+m-7vo$7dunu-hiuhh5r6b-ttv@x@q_@4iu1zc@f' 3 | 4 | # Database Config 5 | DATABASE_NAME=tsuna_streaming_production 6 | DATABASE_USER=postgres 7 | DATABASE_PASSWORD=postgres 8 | DATABASE_HOST=postgres 9 | DATABASE_PORT=5432 10 | 11 | # Postgres Config 12 | POSTGRES_DB=${DATABASE_NAME} 13 | POSTGRES_USER=${DATABASE_USER} 14 | POSTGRES_PASSWORD=${DATABASE_PASSWORD} 15 | 16 | # Email SMTP server config 17 | EMAIL_HOST='smtp.gmail.com' 18 | EMAIL_PORT=1111 19 | EMAIL_USE_TLS=True 20 | EMAIL_USER='' 21 | EMAIL_PASSWORD='' 22 | EMAIL_FROM='contact@tsuna-streaming.com' 23 | 24 | # Celery Config 25 | CELERY_BROKER=redis://redis:6379/1 26 | 27 | # Redis cache db 28 | REDIS_URL=redis://redis:6379/2 29 | 30 | PRODUCTION_DOMAIN="tsuna-streaming.com" 31 | 32 | # Cache Keys 33 | # Key of a subscriber in channel 34 | CACHE_CHANNEL_SUBSCRIBER='subscriber:{channel_token}:{user_token}' 35 | CACHE_OBJECT_VOTE='vote:{channel_token}:{object_token}:{user_token}' 36 | CACHE_OBJECT_VIEWER='viewer:{channel_token}:{object_token}:{user_token}' 37 | CACHE_CONTENT_TYPE_KEY='content_type:{model}:{id}' 38 | 39 | # File Sizes 40 | VIDEO_LIMIT_NORMAL_USER=50 41 | VIDEO_LIMIT_PREMIUM_USER=100 42 | MUSIC_LIMIT_NORMAL_USER=10 43 | MUSIC_LIMIT_PREMIUM_USER=20 44 | 45 | # PG Admin 46 | PGADMIN_DEFAULT_EMAIL=test@test.com 47 | PGADMIN_DEFAULT_PASSWORD=test -------------------------------------------------------------------------------- /backend/config/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, include 3 | from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 4 | 5 | admin.site.site_header = "Tsuna Streaming Admin" 6 | 7 | 8 | LOCAL_APPS = [ 9 | path("accounts/", include("apps.accounts.urls")), 10 | path("memberships/", include("apps.memberships.urls")), 11 | path("channels/", include("apps.channels.urls")), 12 | path("channel_admins/", include("apps.channel_admins.urls")), 13 | path("channel_subscribers/", include("apps.channel_subscribers.urls")), 14 | path("videos/", include("apps.videos.urls")), 15 | path("musics/", include("apps.musics.urls")), 16 | path("votes/", include("apps.votes.urls")), 17 | path("comments/", include("apps.comments.urls")), 18 | path("viewers/", include("apps.viewers.urls")), 19 | ] 20 | 21 | THIRD_PARTY_APPS = [ 22 | # Django Debug Toolbar 23 | path("__debug__/", include("debug_toolbar.urls")), 24 | # api docs 25 | path("", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), 26 | path("docs/download/", SpectacularAPIView.as_view(), name="schema"), 27 | ] 28 | 29 | urlpatterns = [ 30 | path("admin/", admin.site.urls), 31 | *THIRD_PARTY_APPS, 32 | *LOCAL_APPS, 33 | ] 34 | 35 | handler404 = "core.views.error_handler_404" 36 | handler500 = "core.views.error_handler_500" 37 | -------------------------------------------------------------------------------- /backend/apps/memberships/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import pre_save, post_save, pre_delete 2 | from django.dispatch import receiver 3 | from memberships.models import Membership, Subscription 4 | from apps.core.tasks import send_email 5 | 6 | 7 | @receiver(post_save, sender=Subscription) 8 | def notify_user_subscription(sender, created, instance, **kwargs): 9 | """ 10 | Notify a user when a new subscription is created for him 11 | """ 12 | 13 | if created: 14 | subscription = instance 15 | user = subscription.user 16 | 17 | send_email.delay( 18 | template_name="emails/notify_premium.html", 19 | to_email=user.email, 20 | body={ 21 | "first_name": user.first_name, 22 | "membership_title": subscription.membership.title, 23 | "end_date": subscription.end_date, 24 | }, 25 | ) 26 | 27 | 28 | @receiver(pre_delete, sender=Subscription) 29 | def notify_user_plan_expiration(sender, instance, **kwargs): 30 | """ 31 | Notify a user when his subscription is expired 32 | """ 33 | 34 | subscription = instance 35 | 36 | send_email.delay( 37 | template_name="emails/notify_plan_expiration.html", 38 | to_email=subscription.user.email, 39 | body={ 40 | "first_name": subscription.user.first_name, 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0004_token.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-05 08:29 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("accounts", "0003_alter_account_bio_alter_account_picture"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Token", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("token", models.CharField(max_length=32)), 27 | ("date_created", models.DateTimeField(auto_now_add=True)), 28 | ("retry", models.PositiveIntegerField(default=0)), 29 | ( 30 | "user", 31 | models.ForeignKey( 32 | on_delete=django.db.models.deletion.CASCADE, 33 | related_name="tokens", 34 | to=settings.AUTH_USER_MODEL, 35 | ), 36 | ), 37 | ], 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /backend/apps/videos/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save, post_delete 3 | from apps.core.tasks import send_email 4 | from videos.models import Video 5 | 6 | 7 | @receiver(post_save, sender=Video) 8 | def notify_video_creation(sender, instance, created, **kwargs): 9 | """ 10 | Notify Both uploader and channel owner. 11 | """ 12 | 13 | if created: 14 | data = { 15 | "first_name": instance.user.first_name, 16 | "channel_title": instance.title, 17 | "video_token": instance.token, 18 | "channel_token": instance.channel.token, 19 | } 20 | 21 | if instance.user == instance.channel.owner: 22 | send_email.delay( 23 | template_name="emails/notify_video_creation.html", 24 | to_email=instance.user.email, 25 | body={**data}, 26 | ) 27 | 28 | else: 29 | # send email to both admin and owner 30 | send_email.delay( 31 | template_name="emails/notify_video_creation.html", 32 | to_email=instance.user.email, 33 | body={**data}, 34 | ) 35 | 36 | send_email.delay( 37 | template_name="emails/notify_video_creation.html", 38 | to_email=instance.channel.owner.email, 39 | body={**data}, 40 | ) 41 | -------------------------------------------------------------------------------- /backend/apps/accounts/migrations/0009_alter_plan_active_months_alter_plan_description_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2023-01-11 08:31 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("accounts", "0008_plan"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="plan", 15 | name="active_months", 16 | field=models.PositiveBigIntegerField( 17 | default=0, 18 | validators=[ 19 | django.core.validators.MaxValueValidator(24), 20 | django.core.validators.MinValueValidator(1), 21 | ], 22 | ), 23 | ), 24 | migrations.AlterField( 25 | model_name="plan", 26 | name="description", 27 | field=models.TextField(default="No Description available", max_length=400), 28 | ), 29 | migrations.AlterField( 30 | model_name="plan", 31 | name="price", 32 | field=models.PositiveBigIntegerField( 33 | default=10, 34 | validators=[ 35 | django.core.validators.MaxValueValidator(1000), 36 | django.core.validators.MinValueValidator(10), 37 | ], 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/test_managers/test_delete_in_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from channel_subscribers.models import ChannelSubscriber 3 | from channels.models import Channel 4 | from accounts.models import Account 5 | 6 | 7 | @pytest.mark.django_db 8 | class TestDeleteInCache: 9 | def test_delete_in_cache(self, cached_subscriber): 10 | channel = Channel.objects.get(id=cached_subscriber["channel"]) 11 | 12 | ChannelSubscriber.objects.delete_in_cache( 13 | channel=channel, 14 | user=Account.objects.get(id=cached_subscriber["user"]), 15 | ) 16 | assert ChannelSubscriber.objects.get_count(channel=channel) == 1 17 | 18 | def test_delete_invalid_subscriber_in_cache(self, superuser, channel): 19 | with pytest.raises(ValueError): 20 | ChannelSubscriber.objects.delete_in_cache(user=superuser, channel=channel) 21 | 22 | def test_delete_subscriber_in_db(self, subscriber): 23 | assert ChannelSubscriber.objects.get_from_cache( 24 | channel=subscriber.channel, user=subscriber.user 25 | ) 26 | ChannelSubscriber.objects.delete_in_cache( 27 | channel=subscriber.channel, user=subscriber.user 28 | ) 29 | assert not ChannelSubscriber.objects.get_from_cache( 30 | channel=subscriber.channel, user=subscriber.user 31 | ) 32 | assert ChannelSubscriber.objects.get_count(subscriber.channel) == 1 33 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from accounts.models import Account 3 | from accounts.tests.utils import user_credentials 4 | 5 | 6 | @pytest.fixture(scope="class") 7 | def inactive_user(django_db_setup, django_db_blocker) -> Account: 8 | """A fixture to create a inactive user""" 9 | with django_db_blocker.unblock(): 10 | yield Account.objects.create_user(**user_credentials(), is_active=False) 11 | 12 | 13 | @pytest.fixture(scope="class") 14 | def user(django_db_setup, django_db_blocker) -> Account: 15 | """A fixture to create an active user""" 16 | with django_db_blocker.unblock(): 17 | yield Account.objects.create_user(**user_credentials(), is_active=True) 18 | 19 | 20 | @pytest.fixture(scope="class") 21 | def another_user(django_db_setup, django_db_blocker) -> Account: 22 | """A fixture to create an active user""" 23 | with django_db_blocker.unblock(): 24 | yield Account.objects.create_user(**user_credentials(), is_active=True) 25 | 26 | 27 | @pytest.fixture(scope="class") 28 | def superuser(django_db_setup, django_db_blocker) -> Account: 29 | """A fixture to create a superuser""" 30 | with django_db_blocker.unblock(): 31 | yield Account.objects.create_superuser(**user_credentials()) 32 | 33 | 34 | @pytest.fixture(scope="class") 35 | def premium_user(subscription, django_db_blocker, django_db_setup): 36 | with django_db_blocker.unblock(): 37 | yield subscription.user 38 | -------------------------------------------------------------------------------- /backend/apps/accounts/serializers/profile.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from rest_framework import serializers 3 | from memberships.models import Subscription 4 | 5 | 6 | USER = get_user_model() 7 | 8 | 9 | class UserSubscriptionListSerializer(serializers.Serializer): 10 | membership = serializers.CharField(read_only=True) 11 | start_date = serializers.DateTimeField(read_only=True) 12 | end_date = serializers.DateTimeField(read_only=True) 13 | 14 | 15 | class ProfileSerializer(serializers.ModelSerializer): 16 | """ 17 | Serializer for User Profile (Get, Update) 18 | """ 19 | 20 | active_subscription = serializers.SerializerMethodField( 21 | method_name="get_active_subscription", read_only=True 22 | ) 23 | 24 | class Meta: 25 | model = USER 26 | fields = [ 27 | "email", 28 | "first_name", 29 | "last_name", 30 | "picture", 31 | "bio", 32 | "token", 33 | "active_subscription", 34 | ] 35 | read_only_fields = [ 36 | "email", 37 | "token", 38 | ] 39 | 40 | def get_active_subscription(self, obj): 41 | """ 42 | Returns the active subscription for the given user. 43 | """ 44 | serializer = UserSubscriptionListSerializer( 45 | Subscription.objects.get_active_subscription(user=obj) 46 | ) 47 | return serializer.data 48 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/tests/models/test_channel_admin.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db.utils import IntegrityError 3 | from django.core.exceptions import PermissionDenied 4 | from channel_admins.models import ChannelAdmin 5 | from channel_admins.exceptions import SubscriptionRequiredException 6 | from accounts.models import Account 7 | from channels.models import Channel 8 | 9 | 10 | @pytest.mark.django_db 11 | class TestAdminModel: 12 | def test_channel(self, channel, subscriber): 13 | ChannelAdmin.objects.create( 14 | user=subscriber.user, channel=channel, promoted_by=channel.owner 15 | ) 16 | 17 | def test_create_channel_admin_twice_fails(self, channel): 18 | with pytest.raises(IntegrityError): 19 | ChannelAdmin.objects.create( 20 | user=channel.owner, channel=channel, promoted_by=channel.owner 21 | ) 22 | 23 | def test_create_no_subscription_fails(self, channel, another_user): 24 | with pytest.raises(SubscriptionRequiredException): 25 | ChannelAdmin.objects.create( 26 | user=another_user, channel=channel, promoted_by=channel.owner 27 | ) 28 | 29 | def test_promote_by_non_owner_fails(self, channel, channel_admin): 30 | with pytest.raises(PermissionDenied): 31 | ChannelAdmin.objects.create( 32 | user=channel_admin.user, 33 | channel=channel, 34 | promoted_by=channel_admin.user, 35 | ) 36 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Tsuna Streaming Tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | 9 | env: 10 | REGISTRY: docker.io 11 | IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/tsuna_streaming 12 | 13 | jobs: 14 | build-and-push: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Build Docker Image 19 | uses: ./.github/actions/build 20 | with: 21 | image_name: ${{ env.IMAGE_NAME }} 22 | registry: ${{ env.REGISTRY }} 23 | dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} 24 | dockerhub_password: ${{ secrets.DOCKERHUB_PASSWORD }} 25 | 26 | 27 | test-on-docker: 28 | needs: build-and-push 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v3 32 | - name: Test Docker Image 33 | uses: ./.github/actions/test-docker 34 | with: 35 | image_name: ${{ env.IMAGE_NAME }} 36 | registry: ${{ env.REGISTRY }} 37 | 38 | test-on-kubernetes: 39 | needs: build-and-push 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v3 43 | - name: Test Kubernetes 44 | uses: ./.github/actions/test-kubernetes 45 | 46 | deploy: 47 | needs: [ test-on-docker, test-on-kubernetes ] 48 | runs-on: ubuntu-latest 49 | steps: 50 | - uses: actions/checkout@v3 51 | - name: Deploy The Code 52 | uses: ./.github/actions/deploy 53 | -------------------------------------------------------------------------------- /backend/apps/memberships/models/membership.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.core.validators import MinValueValidator, MaxValueValidator 3 | from memberships.exceptions import MembershipInUserError 4 | from core.models import AbstractToken 5 | 6 | 7 | class Membership(AbstractToken): 8 | """ 9 | Membership model is used to store the membership plans. 10 | """ 11 | 12 | title = models.CharField(max_length=210) 13 | description = models.TextField(max_length=400, default="No Description available") 14 | 15 | price = models.PositiveBigIntegerField( 16 | default=10, validators=[MaxValueValidator(1000), MinValueValidator(10)] 17 | ) 18 | 19 | active_months = models.PositiveBigIntegerField( 20 | default=0, validators=[MaxValueValidator(24), MinValueValidator(1)] 21 | ) 22 | 23 | is_available = models.BooleanField(default=False) 24 | 25 | def __str__(self) -> str: 26 | return self.title 27 | 28 | def delete(self, *args, **kwargs): 29 | """ 30 | Only memberships without active subscription can be deleted 31 | """ 32 | 33 | # Check membership is in use 34 | self.__check_in_use() 35 | return super(Membership, self).delete(*args, **kwargs) 36 | 37 | def __check_in_use(self) -> None: 38 | """ 39 | Raise MembershipInUserError if trying to delete an in use plan 40 | """ 41 | if self.subscriptions.count(): 42 | raise MembershipInUserError("Plan is in use") 43 | -------------------------------------------------------------------------------- /backend/apps/channel_subscribers/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from django.db.utils import IntegrityError 2 | from django.core.cache import caches 3 | 4 | import pytest 5 | from channel_subscribers.models import ChannelSubscriber 6 | 7 | 8 | @pytest.mark.django_db 9 | class TestChannelSubscriber: 10 | def test_auto_create_sub_for_channel_owner(self, channel): 11 | """Ensure that a new channel automatically subscribes its owner.""" 12 | assert channel.subscribers.count() == 1 13 | 14 | def test_raise_error_for_subscribing_twice(self, channel): 15 | """Ensure that users can only subscribe to a channel once.""" 16 | with pytest.raises(IntegrityError): 17 | ChannelSubscriber.objects.create(user=channel.owner, channel=channel) 18 | 19 | def test_delete_subscriber(self, subscriber): 20 | assert subscriber.channel.subscribers.count() == 2 21 | subscriber.delete() 22 | assert subscriber.channel.subscribers.count() == 1 23 | 24 | def test_get_channel_subscriber_count(self, channel, superuser): 25 | assert ( 26 | ChannelSubscriber.objects.get_count(channel) == 2 27 | ) # One in cache + one in db 28 | ChannelSubscriber.objects.all().delete() 29 | # Delete the old subscriber count in cache 30 | for cache_backend in caches.all(): 31 | cache_backend.clear() 32 | ChannelSubscriber.objects.create(user=superuser, channel=channel) 33 | assert ChannelSubscriber.objects.get_count(channel) == 1 34 | -------------------------------------------------------------------------------- /backend/apps/accounts/models/account.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import AbstractUser 3 | from accounts.validators import validate_profile_size 4 | from accounts.managers import AccountManager 5 | from .role import AbstractAccountRole 6 | from core.models import AbstractToken 7 | 8 | 9 | class Account(AbstractUser, AbstractToken, AbstractAccountRole): 10 | username = None 11 | email = models.EmailField(max_length=200, unique=True) 12 | 13 | picture = models.ImageField( 14 | upload_to="accounts/profile", 15 | default="assets/images/default-user-profile.jpg", 16 | validators=[validate_profile_size], 17 | ) 18 | 19 | first_name = models.CharField(max_length=50) 20 | last_name = models.CharField(max_length=50, blank=True, null=True) 21 | 22 | bio = models.TextField( 23 | max_length=250, blank=True, default="Hey there, i am using tsuna streaming." 24 | ) 25 | 26 | is_active = models.BooleanField(default=False) 27 | 28 | EMAIL_FIELD = "email" 29 | USERNAME_FIELD = "email" 30 | REQUIRED_FIELDS = ["first_name", "last_name", "bio"] 31 | 32 | objects = AccountManager() 33 | 34 | def __str__(self) -> str: 35 | return f"{self.first_name} {self.last_name}" 36 | 37 | @property 38 | def full_name(self) -> str: 39 | """Concatenate first name and last name""" 40 | return f"{self.first_name} {self.last_name}" 41 | 42 | class Meta: 43 | app_label = "accounts" 44 | db_table = "accounts_account" 45 | -------------------------------------------------------------------------------- /backend/apps/memberships/tests/views/test_create.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestMembershipCreateView: 8 | def setup(self): 9 | self.url = reverse("memberships:membership") 10 | self.data = { 11 | "title": "Test Membership", 12 | "description": "Test Membership Description", 13 | "price": 100, 14 | "active_months": 6, 15 | "is_available": True, 16 | } 17 | 18 | def test_get_unauthorized(self, api_client): 19 | response = api_client.post(self.url, self.data, format="json") 20 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 21 | 22 | def test_get_by_superuser(self, superuser, api_client): 23 | api_client.force_authenticate(user=superuser) 24 | response = api_client.post(self.url, self.data, format="json") 25 | assert response.status_code == status.HTTP_201_CREATED 26 | 27 | def test_get_by_premium_user(self, premium_user, api_client): 28 | api_client.force_authenticate(user=premium_user) 29 | response = api_client.post(self.url, self.data, format="json") 30 | assert response.status_code == status.HTTP_403_FORBIDDEN 31 | 32 | def test_get_by_normal_user(self, user, api_client): 33 | api_client.force_authenticate(user=user) 34 | response = api_client.post(self.url, self.data, format="json") 35 | assert response.status_code == status.HTTP_403_FORBIDDEN 36 | -------------------------------------------------------------------------------- /backend/apps/accounts/tests/models/test_verification_token.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import timedelta 3 | from django.utils import timezone 4 | from django.core.exceptions import ValidationError, PermissionDenied 5 | from accounts.models import VerificationToken 6 | 7 | 8 | @pytest.mark.django_db 9 | class TestTokenModel: 10 | @pytest.fixture(autouse=True) 11 | def setup(self, inactive_user): 12 | self.user = inactive_user 13 | self.token = self.user.verification_tokens.first() 14 | 15 | def test_token_is_created_for_inactive_user(self): 16 | assert self.token.token is not None 17 | 18 | def test_create_for_active_account(self, user): 19 | with pytest.raises(ValidationError): 20 | VerificationToken.objects.create(user=user) 21 | 22 | def test_token_is_valid(self): 23 | assert self.token.is_valid is True 24 | 25 | def test_create_duplicate_fails(self): 26 | assert self.user.verification_tokens.count() == 1 27 | with pytest.raises(PermissionDenied): 28 | VerificationToken.objects.create(user=self.user) 29 | 30 | def test_token_is_expired(self): 31 | """Token is expired after 10 minutes""" 32 | self.token.expire_at = timezone.now() - timedelta(minutes=11) 33 | self.token.save() 34 | assert self.token.is_valid is False 35 | 36 | def test_delete_token_after_user_deletion(self): 37 | verification_id = self.token.id 38 | self.user.delete() 39 | assert not VerificationToken.objects.filter(id=verification_id).exists() 40 | -------------------------------------------------------------------------------- /backend/apps/channels/tests/views/test_delete.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | from rest_framework import status 4 | 5 | 6 | @pytest.mark.django_db 7 | class TestChannelDelete: 8 | @pytest.fixture(autouse=True) 9 | def setup(self, channel): 10 | self.url_name = "channels:channel_detail" 11 | self.channel = channel 12 | 13 | def test_delete_unauthorized(self, api_client): 14 | url = reverse(self.url_name, kwargs={"channel_token": self.channel.token}) 15 | response = api_client.delete(url) 16 | assert response.status_code == status.HTTP_401_UNAUTHORIZED 17 | 18 | def test_delete_by_channel_owner(self, api_client): 19 | url = reverse(self.url_name, kwargs={"channel_token": self.channel.token}) 20 | api_client.force_authenticate(user=self.channel.owner) 21 | response = api_client.delete(url) 22 | assert response.status_code == status.HTTP_204_NO_CONTENT 23 | 24 | def test_delete_by_others(self, api_client, superuser): 25 | url = reverse(self.url_name, kwargs={"channel_token": self.channel.token}) 26 | api_client.force_authenticate(user=superuser) 27 | response = api_client.delete(url) 28 | assert response.status_code == status.HTTP_403_FORBIDDEN 29 | 30 | def test_delete_not_found(self, api_client, create_unique_uuid, user): 31 | url = reverse(self.url_name, kwargs={"channel_token": create_unique_uuid}) 32 | api_client.force_authenticate(user=user) 33 | response = api_client.delete(url) 34 | assert response.status_code == status.HTTP_404_NOT_FOUND 35 | -------------------------------------------------------------------------------- /backend/apps/channel_admins/models/permission.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from channel_admins.models import ChannelAdmin 3 | from core.models import AbstractToken 4 | 5 | 6 | class ChannelAdminPermission(AbstractToken): 7 | """ 8 | Represents Permissions of a channel admin. 9 | """ 10 | 11 | admin = models.OneToOneField( 12 | ChannelAdmin, on_delete=models.CASCADE, related_name="permissions" 13 | ) 14 | 15 | # Permissions 16 | can_add_object = models.BooleanField(default=False) 17 | can_edit_object = models.BooleanField(default=False) 18 | can_delete_object = models.BooleanField(default=False) 19 | can_publish_object = models.BooleanField(default=False) 20 | can_change_channel_info = models.BooleanField(default=False) 21 | 22 | def __str__(self) -> str: 23 | return str(self.admin.user) 24 | 25 | PERMISSION_FIELDS = [ 26 | "can_add_object", 27 | "can_edit_object", 28 | "can_delete_object", 29 | "can_publish_object", 30 | "can_change_channel_info", 31 | ] 32 | 33 | def save(self, *args, **kwargs): 34 | if not self.pk: 35 | # Set owner permission to True 36 | ChannelAdminPermission.set_owner_permission(admin=self) 37 | return super().save(*args, **kwargs) 38 | 39 | @classmethod 40 | def set_owner_permission(cls, admin) -> None: 41 | """ 42 | If user is channel owner, set all permissions to True 43 | """ 44 | if admin.admin.channel.owner == admin.admin.user: 45 | for field in cls.PERMISSION_FIELDS: 46 | setattr(admin, field, True) 47 | -------------------------------------------------------------------------------- /backend/apps/core/utils/content_type_model.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.db import models 3 | from django.core.cache import cache 4 | from typing import Union 5 | from core.constants import CACHE_CONTENT_TYPE_KEY 6 | 7 | 8 | def get_content_type_model( 9 | model: Union[models.Model, str] = "*", 10 | _id: Union[int, str] = "*", 11 | ) -> ContentType: 12 | """ 13 | Get the content type for a model and return it. 14 | :param model: The model to get the content type for 15 | :param _id: The id of the content type 16 | """ 17 | if model == "*" and _id == "*": 18 | raise ValueError("Either model or id must be provided") 19 | 20 | cached = cache.get_many( 21 | cache.keys(CACHE_CONTENT_TYPE_KEY.format(model=model, id=_id)) 22 | ) 23 | 24 | if not cached: 25 | try: 26 | filters = {} 27 | 28 | if model != "*": 29 | filters["model"] = model.__name__.lower() 30 | 31 | if _id != "*": 32 | filters["id"] = _id 33 | 34 | content_type = ContentType.objects.get(**filters) 35 | 36 | # Set in cache if found 37 | cache.set( 38 | key=CACHE_CONTENT_TYPE_KEY.format( 39 | model=content_type, id=content_type.id 40 | ), 41 | value={"id": content_type.id, "model": content_type}, 42 | ) 43 | return content_type 44 | 45 | except ContentType.DoesNotExist: 46 | raise ValueError(f"Content type not found") 47 | 48 | return next(iter(cached.values()))["model"] 49 | --------------------------------------------------------------------------------