diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b46c067..4c18758 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,11 @@ env: on: pull_request: - branches: [ "master" ] + branches: [ "master", "develop" ] paths-ignore: [ "docs/**" ] push: - branches: [ "master" ] + branches: [ "master", "develop" ] paths-ignore: [ "docs/**" ] @@ -56,4 +56,4 @@ jobs: run: docker-compose -f local.yml exec -T django pytest - name: Tear down the Stack - run: docker-compose down + run: docker-compose -f local.yml down diff --git a/.gitignore b/.gitignore index 36e6dc4..4d37d23 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +match_datas + ### Python template # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index b476273..5a4dd8a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,191 @@ # AlgoSports Backend +## Development -## Installation +### Branch +- 아래 포스트의 convention을 일부 차용해서 사용한다. + - https://dev.to/couchcamote/git-branching-name-convention-cch -## Development +1. master + - 실제 배포 +2. develop + - 개발 및 테스트 +3. temporary branches + + - feat/ + - 기능 개발을 다루는 branch. + - bugfix/ + - feature에서 발생한 버그를 다루는 branch. + - hotfix/ + - master 브랜치에 바로 반영해야하는 버그를 다루는 branch. + - experimental/ + - 테스트하고 싶은 기능을 다루는 branch. + +### Installation + +1. Activate virtual environment +2. Install packages + ```shell + pip install -r requirements/local.txt + ``` +### Utility -## Test +- 아래 커맨드로 확인 가능 +```shell +fab -l +``` + +### Start local server + +- API 서버 + ```shell + fab runserver + ``` +- Celery broker + ```shell + redis-server + ``` +- Celery worker + ```shell + fab celery + ``` + +### Test + +```shell +pytest . +mypy . +``` ## Build & Deploy + +### 사전 준비물 + +- AWS CLI2 설치 + ```shell + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" + unzip awscliv2.zip + sudo ./aws/install + ``` +- ECS-CLI 설치 + ```shell + sudo curl -Lo /usr/local/bin/ecs-cli https://amazon-ecs-cli.s3.amazonaws.com/ecs-cli-linux-amd64-latest + sudo chmod +x /usr/local/bin/ecs-cli + ``` +- IAM 계정 생성 및 정책 연결 + ![IAM_정책](./docs/IAM_정책.png) + +### ECS-CLI로 클러스터 생성 + +- 생성 후 만들어진 VPC, Subnets을 저장해둬야 합니다. +- key-pair를 먼저 생성하고 key-pair이름을 저장해둡니다. + +```shell +# 환경변수 설정 +AWS_DEFAULT_REGION=ap-northeast-2 +CLUSTER_NAME=algo-cluster2 +CONFIG_NAME=algo-config +PROFILE_NAME=algo-profile +INSTANCE_SIZE=3 + +KEY_PAIR=algo-keypair + +# 클러스터 및 프로필 설정 +ecs-cli configure --cluster $CLUSTER_NAME --region $AWS_DEFAULT_REGION --default-launch-type EC2 --config-name $CONFIG_NAME +ecs-cli configure profile --access-key $AWS_ACCESS_KEY_ID --secret-key $AWS_SECRET_ACCESS_KEY --profile-name $PROFILE_NAME +ecs-cli configure profile default --profile-name $PROFILE_NAME + +# user-data 생성 (ecs-cluster에 연결하는 역할) +echo "#!/bin/bash \necho ECS_CLUSTER=jts-cluster >> /etc/ecs/ecs.config" > user_data.sh + +# 클러스터 생성 +ecs-cli up \ + --capability-iam \ + --keypair $KEY_PAIR \ + --size $INSTANCE_SIZE \ + --launch-type EC2 \ + --extra-user-data ./aws/user_data.sh +``` + +### Security Group 생성 및 80포트 오픈 + +```shell +VPC_ID= +SG_NAME=algo-sg + +# Security group 생성 및 GROUP_ID 저장 +SG_GROUP_ID=$(aws ec2 create-security-group \ + --group-name "$SG_NAME" \ + --description "Security Group for ECS $CLUSTER_NAME" \ + --vpc-id $VPC_ID | + jq -r ".GroupId") + +aws ec2 authorize-security-group-ingress \ + --group-id $SG_GROUP_ID \ + --protocol tcp \ + --port 80 \ + --cidr 0.0.0.0/0 +``` + +### ECR 로그인 및 레포지토리 생성 + +```shell +# ECR 레포지토리 생성 +aws ecr create-repository --repository-name django | + jq ".repository | .repositoryUri" + +aws ecr create-repository --repository-name nginx | + jq ".repository | .repositoryUri" +``` + +### ECR 저장소 이미지 배포 + +```shell +# ECR, Docker 로그인 +aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $(aws sts get-caller-identity --query Account --output text).dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/ + +docker-compose -f staging.yml -f production.yml build +docker-compose -f staging.yml -f production.yml push django nginx +``` + +### ECS Task IAM role 생성 + +```shell +# 빈 iam role 생성 +aws iam create-role --role-name ecs-instance --assume-role-policy-document file://aws/assume-role.json + +# 필요한 정책 추가 +aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role --role-name ecs-instance + +# 인스턴스 프로필 생성 +aws iam create-instance-profile --instance-profile-name ecs-instance-profile + +# 해당 프로필에 롤 추가 +aws iam add-role-to-instance-profile --instance-profile-name ecs-instance-profile --role-name ecs-instance + +# instasnce profiles 출력 +aws iam list-instance-profiles +``` + +### 서비스 생성 + +```shell +CONFIG_NAME=algo-config +PROJECT_NAME=algo-service + +ecs-cli compose \ + --project-name $PROJECT_NAME \ + --file staging.yml \ + --file production.yml \ + --ecs-params ./aws/ecs-params.yml \ + service up \ + --create-log-groups \ + --cluster-config $CONFIG_NAME \ + --container-name nginx \ + --container-port 80 \ + --target-group-arn arn:aws:elasticloadbalancing:ap-northeast-2:648240308375:targetgroup/target/e3086c4f494a30c4 \ + --launch-type EC2 +``` diff --git a/algo_sports/blogs/__init__.py b/algo_sports/blogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/algo_sports/blogs/admin.py b/algo_sports/blogs/admin.py new file mode 100644 index 0000000..9e26b09 --- /dev/null +++ b/algo_sports/blogs/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin + +from .models import Blog, Comment, Post + + +@admin.register(Blog) +class BlogAdmin(admin.ModelAdmin): + pass + + +@admin.register(Post) +class PostAdmin(admin.ModelAdmin): + pass + + +@admin.register(Comment) +class CommentAdmin(admin.ModelAdmin): + pass diff --git a/algo_sports/blogs/apps.py b/algo_sports/blogs/apps.py new file mode 100644 index 0000000..4a501cd --- /dev/null +++ b/algo_sports/blogs/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class BlogConfig(AppConfig): + name = "algo_sports.blogs" + verbose_name = _("Blogs") diff --git a/algo_sports/blogs/filters.py b/algo_sports/blogs/filters.py new file mode 100644 index 0000000..0785b86 --- /dev/null +++ b/algo_sports/blogs/filters.py @@ -0,0 +1,30 @@ +from django_filters import rest_framework as filters +from django_filters.filters import CharFilter + +from .models import Blog, Comment, Post + + +class BlogFilter(filters.FilterSet): + class Meta: + model = Blog + fields = ( + "category", + "permission", + ) + + +class PostFilter(filters.FilterSet): + blog = CharFilter(field_name="blog_id__category") + + class Meta: + model = Post + fields = ("title",) + + +class CommentFilter(filters.FilterSet): + class Meta: + model = Comment + fields = ( + "post_id", + "parent_id", + ) diff --git a/algo_sports/blogs/migrations/0001_initial.py b/algo_sports/blogs/migrations/0001_initial.py new file mode 100644 index 0000000..c1fb686 --- /dev/null +++ b/algo_sports/blogs/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 3.1.3 on 2020-11-17 21:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Blog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('category', models.CharField(max_length=2, verbose_name='Blog cateogry')), + ('permission', models.CharField(choices=[('AD', 'Admin only'), ('ST', 'Staff only'), ('AL', 'Allow any')], default='AL', max_length=2, verbose_name='Blog permission')), + ('description', models.TextField(max_length=2, verbose_name='Blog description')), + ], + options={ + 'ordering': ['category'], + }, + ), + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField(verbose_name='Post title')), + ('content', models.TextField(verbose_name='Post content')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('blog_id', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to='blogs.blog', verbose_name='Post of comment')), + ('user_id', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to=settings.AUTH_USER_MODEL, verbose_name='Post author')), + ], + options={ + 'ordering': ['-id'], + }, + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('deleted', models.BooleanField(default=False, verbose_name='Is Comment deleted?')), + ('content', models.CharField(max_length=300, verbose_name='Comment content')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('parent_id', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='childs', to='blogs.comment', verbose_name='Parent Comment')), + ('post_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='comments', to='blogs.post', verbose_name='Comment of post')), + ('user_id', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='comments', to=settings.AUTH_USER_MODEL, verbose_name='Comment author')), + ], + options={ + 'ordering': ['-id'], + }, + ), + ] diff --git a/algo_sports/blogs/migrations/0002_auto_20201119_0024.py b/algo_sports/blogs/migrations/0002_auto_20201119_0024.py new file mode 100644 index 0000000..1338e3a --- /dev/null +++ b/algo_sports/blogs/migrations/0002_auto_20201119_0024.py @@ -0,0 +1,29 @@ +# Generated by Django 3.1.3 on 2020-11-19 00:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('blogs', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='blog', + name='category', + field=models.SlugField(max_length=2, unique=True, verbose_name='Blog cateogry'), + ), + migrations.AlterField( + model_name='blog', + name='permission', + field=models.PositiveSmallIntegerField(choices=[(3, 'Admin only'), (2, 'Staff only'), (1, 'Allow any')], default=1, verbose_name='Blog permission'), + ), + migrations.AlterField( + model_name='post', + name='blog_id', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to='blogs.blog', verbose_name='Blog of post'), + ), + ] diff --git a/algo_sports/blogs/migrations/0003_auto_20201120_1843.py b/algo_sports/blogs/migrations/0003_auto_20201120_1843.py new file mode 100644 index 0000000..7daf73c --- /dev/null +++ b/algo_sports/blogs/migrations/0003_auto_20201120_1843.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.3 on 2020-11-20 18:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blogs', '0002_auto_20201119_0024'), + ] + + operations = [ + migrations.AlterField( + model_name='blog', + name='category', + field=models.SlugField(unique=True, verbose_name='Blog cateogry'), + ), + migrations.AlterField( + model_name='blog', + name='description', + field=models.CharField(max_length=200, verbose_name='Blog description'), + ), + migrations.AlterField( + model_name='post', + name='title', + field=models.CharField(max_length=200, verbose_name='Post title'), + ), + ] diff --git a/algo_sports/blogs/migrations/0004_auto_20201215_0016.py b/algo_sports/blogs/migrations/0004_auto_20201215_0016.py new file mode 100644 index 0000000..4306789 --- /dev/null +++ b/algo_sports/blogs/migrations/0004_auto_20201215_0016.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.3 on 2020-12-15 00:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('blogs', '0003_auto_20201120_1843'), + ] + + operations = [ + migrations.AlterModelOptions( + name='comment', + options={'ordering': ['id']}, + ), + ] diff --git a/algo_sports/blogs/migrations/__init__.py b/algo_sports/blogs/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/algo_sports/blogs/models.py b/algo_sports/blogs/models.py new file mode 100644 index 0000000..4fc51f4 --- /dev/null +++ b/algo_sports/blogs/models.py @@ -0,0 +1,125 @@ +from django.contrib.auth import get_user_model +from django.db import models + +from algo_sports.utils.choices import PermissionChoices + +User = get_user_model() + + +class Blog(models.Model): + category = models.SlugField("Blog cateogry", max_length=50, unique=True) + permission = models.PositiveSmallIntegerField( + "Blog permission", + choices=PermissionChoices.choices, + default=PermissionChoices.ALL, + ) + description = models.CharField("Blog description", max_length=200) + + class Meta: + ordering = ["category"] + + def __str__(self) -> str: + return f"{self.category}" + + @property + def gameinfo(self): + return self.gameinfo_id + + def get_posts(self): + return self.posts.all() + + +class Post(models.Model): + user_id = models.ForeignKey( + User, + verbose_name="Post author", + null=True, + on_delete=models.SET_NULL, + related_name="posts", + ) + blog_id = models.ForeignKey( + Blog, + verbose_name="Blog of post", + null=True, + on_delete=models.SET_NULL, + related_name="posts", + ) + + title = models.CharField("Post title", max_length=200) + content = models.TextField("Post content") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-id"] + + def __str__(self) -> str: + return self.title + + @property + def user(self): + return self.user_id + + @property + def blog(self): + return self.blog_id + + def get_comments(self): + return self.comments.all() + + +class Comment(models.Model): + post_id = models.ForeignKey( + Post, + verbose_name="Comment of post", + on_delete=models.PROTECT, + related_name="comments", + ) + user_id = models.ForeignKey( + User, + verbose_name="Comment author", + null=True, + on_delete=models.SET_NULL, + related_name="comments", + ) + parent_id = models.ForeignKey( + "Comment", + null=True, + blank=True, + verbose_name="Parent Comment", + on_delete=models.PROTECT, + related_name="childs", + ) + + deleted = models.BooleanField("Is Comment deleted?", default=False) + content = models.CharField("Comment content", max_length=300) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["id"] + + def __str__(self) -> str: + return f"Comment({self.id})" + + @property + def post(self): + return self.post_id + + @property + def user(self): + return self.user_id + + @property + def parent(self): + return self.parent_id + + def fake_delete(self): + """ Fake delete """ + self.deleted = True + + def get_childs(self): + """ Get child comments """ + return self.childs.all() diff --git a/algo_sports/blogs/permissions.py b/algo_sports/blogs/permissions.py new file mode 100644 index 0000000..25a8a13 --- /dev/null +++ b/algo_sports/blogs/permissions.py @@ -0,0 +1,13 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsCommentNotDeleted(BasePermission): + """ + 삭제된 Comment에는 수정, 삭제 불가 + """ + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + + return not obj.deleted diff --git a/algo_sports/blogs/serializers.py b/algo_sports/blogs/serializers.py new file mode 100644 index 0000000..ea27647 --- /dev/null +++ b/algo_sports/blogs/serializers.py @@ -0,0 +1,68 @@ +from rest_framework import serializers + +from algo_sports.users.serializers import UsernameSerializer + +from .models import Blog, Comment, Post + + +class BlogSerializer(serializers.ModelSerializer): + class Meta: + model = Blog + fields = [ + "category", + "permission", + "description", + ] + + +class PostSerializer(serializers.ModelSerializer): + user = UsernameSerializer(read_only=True) + blog = serializers.StringRelatedField() + + class Meta: + model = Post + fields = [ + "id", + "title", + "user", + "blog", + "content", + "created_at", + "updated_at", + ] + + +class ReCommentSerializer(serializers.ModelSerializer): + user = UsernameSerializer(read_only=True) + + class Meta: + model = Comment + exclude = ["post_id", "user_id", "parent_id"] + + def to_representation(self, instance): + """ Deleted comment의 전송되는 필드 제한 """ + representation = super().to_representation(instance) + if instance.deleted: + exclude_fields = ("user", "content") + for field in exclude_fields: + representation.pop(field) + return representation + + +class CommentSerializer(serializers.ModelSerializer): + user = UsernameSerializer(read_only=True) + post = serializers.PrimaryKeyRelatedField(read_only=True) + recomments = ReCommentSerializer(source="get_childs", many=True, required=False) + + class Meta: + model = Comment + exclude = ["post_id", "user_id", "parent_id"] + + def to_representation(self, instance): + """ Deleted comment의 전송되는 필드 제한 """ + representation = super().to_representation(instance) + if instance.deleted: + exclude_fields = ("user", "content") + for field in exclude_fields: + representation.pop(field) + return representation diff --git a/algo_sports/blogs/tests/__init__.py b/algo_sports/blogs/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/algo_sports/blogs/tests/factories.py b/algo_sports/blogs/tests/factories.py new file mode 100644 index 0000000..0dac634 --- /dev/null +++ b/algo_sports/blogs/tests/factories.py @@ -0,0 +1,41 @@ +from factory import Faker, Sequence, fuzzy +from factory.declarations import SubFactory +from factory.django import DjangoModelFactory + +from algo_sports.blogs.models import Blog, Comment, Post +from algo_sports.users.tests.factories import UserFactory +from algo_sports.utils.choices import PermissionChoices + + +class BlogFactory(DjangoModelFactory): + category = Sequence(lambda n: f"Category {n}") + permission = fuzzy.FuzzyChoice(PermissionChoices.values) + description = Faker("sentence") + + class Meta: + model = Blog + + +class PostFactory(DjangoModelFactory): + title = Faker("sentence") + user_id = SubFactory(UserFactory) + blog_id = SubFactory(BlogFactory) + + content = Faker("sentence") + + class Meta: + model = Post + + +class CommentFactory(DjangoModelFactory): + post_id = SubFactory(PostFactory) + user_id = SubFactory(UserFactory) + + content = Faker("sentence") + + class Meta: + model = Comment + + +def make_recomments(size: int, comment: Comment): + return CommentFactory.create_batch(size, post_id=comment.post, parent_id=comment) diff --git a/algo_sports/blogs/tests/test_blog_viewset.py b/algo_sports/blogs/tests/test_blog_viewset.py new file mode 100644 index 0000000..1c698df --- /dev/null +++ b/algo_sports/blogs/tests/test_blog_viewset.py @@ -0,0 +1,83 @@ +import pytest +from django.test import RequestFactory +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from algo_sports.blogs.models import Blog +from algo_sports.blogs.tests.factories import BlogFactory +from algo_sports.blogs.views import BlogViewSet +from algo_sports.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestBlogVIewSet: + def test_get_queryset(self, rf: RequestFactory): + # 기본 세팅 + view = BlogViewSet() + request = rf.get("/fake-url/") + + users = [ + UserFactory(is_superuser=True), + UserFactory(is_staff=True), + UserFactory(), + ] + + BlogFactory.create_batch(100) + + for user in users: + request.user = user + view.request = request + permissions = view.get_queryset().values_list("permission", flat=True) + + for permission in permissions: + assert permission <= user.level + + def test_list(self): + client = APIClient() + + users = [ + UserFactory(), + UserFactory(is_staff=True), + UserFactory(is_superuser=True), + ] + + BlogFactory.create_batch(100) + + for user in users: + client.force_authenticate(user=user) + blog_list_url = reverse("api:blog-list") + response = client.get(blog_list_url) + + blogs = Blog.objects.all().filter(permission__lte=user.level).count() + allowed_blogs = len(response.json()) + + assert blogs == allowed_blogs + + def test_add_post(self): + client = APIClient() + + users = [ + UserFactory(), + UserFactory(is_staff=True), + UserFactory(is_superuser=True), + ] + + BlogFactory.create_batch(100) + + for user in users: + client.force_authenticate(user=user) + blogs = Blog.objects.all() + for blog in blogs: + url = reverse("api:blog-add-post", kwargs={"category": blog.category}) + response = client.post( + url, + data={"title": "Hello", "content": "How are you?"}, + ) + if blog.permission <= user.level: + # 블로그 권한이 더 낮을 시 201 + assert response.status_code == status.HTTP_201_CREATED + else: + # 블로그 권한이 더 높을 시 쿼리 불가 404 + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/algo_sports/blogs/tests/test_blogs_urls.py b/algo_sports/blogs/tests/test_blogs_urls.py new file mode 100644 index 0000000..bb28769 --- /dev/null +++ b/algo_sports/blogs/tests/test_blogs_urls.py @@ -0,0 +1,85 @@ +import pytest +from django.urls import resolve, reverse + +from algo_sports.blogs.tests.factories import BlogFactory, CommentFactory, PostFactory +from algo_sports.utils.test.compare_url import compare_url + +pytestmark = pytest.mark.django_db + + +def test_blog_url(): + blog = BlogFactory() + lookup_field = "category" + lookup_value = blog.category + + # reverse url + assert compare_url( + reverse("api:blog-detail", kwargs={lookup_field: lookup_value}), + f"/api/blogs/{lookup_value}/", + ) + assert compare_url(reverse("api:blog-list"), "/api/blogs/") + assert compare_url( + reverse("api:blog-add-post", kwargs={lookup_field: lookup_value}), + f"/api/blogs/{lookup_value}/add_post/", + ) + + # resolve view name + assert resolve(f"/api/blogs/{lookup_value}/").view_name == "api:blog-detail" + assert resolve("/api/blogs/").view_name == "api:blog-list" + assert ( + resolve(f"/api/blogs/{lookup_value}/add_post/").view_name == "api:blog-add-post" + ) + + +def test_post_url(): + post = PostFactory() + lookup_field = "pk" + lookup_value = post.pk + + # reverse url + assert compare_url( + reverse("api:post-detail", kwargs={lookup_field: lookup_value}), + f"/api/posts/{lookup_value}/", + ) + assert compare_url(reverse("api:post-list"), "/api/posts/") + assert compare_url( + reverse("api:post-add-comment", kwargs={lookup_field: lookup_value}), + f"/api/posts/{lookup_value}/add_comment/", + ) + + # resolve view name + assert resolve(f"/api/posts/{lookup_value}/").view_name == "api:post-detail" + assert resolve("/api/posts/").view_name == "api:post-list" + assert ( + resolve(f"/api/posts/{lookup_value}/add_comment/").view_name + == "api:post-add-comment" + ) + + +def test_comment_url(): + comment = CommentFactory() + lookup_field = "pk" + lookup_value = comment.pk + + # reverse url + assert compare_url( + reverse("api:comment-detail", kwargs={lookup_field: lookup_value}), + f"/api/comments/{lookup_value}/", + ) + assert compare_url(reverse("api:comment-list"), "/api/comments/") + assert compare_url( + reverse("api:comment-add-recomment", kwargs={lookup_field: lookup_value}), + f"/api/comments/{lookup_value}/add_recomment/", + ) + assert compare_url( + reverse("api:comment-add-recomment", kwargs={lookup_field: lookup_value}), + f"/api/comments/{lookup_value}/add_recomment/", + ) + + # resolve view name + assert resolve(f"/api/comments/{lookup_value}/").view_name == "api:comment-detail" + assert resolve("/api/comments/").view_name == "api:comment-list" + assert ( + resolve(f"/api/comments/{lookup_value}/add_recomment/").view_name + == "api:comment-add-recomment" + ) diff --git a/algo_sports/blogs/tests/test_comment_viewset.py b/algo_sports/blogs/tests/test_comment_viewset.py new file mode 100644 index 0000000..b6c7419 --- /dev/null +++ b/algo_sports/blogs/tests/test_comment_viewset.py @@ -0,0 +1,107 @@ +import random + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient, APIRequestFactory + +from algo_sports.blogs.tests.factories import CommentFactory, PostFactory +from algo_sports.blogs.views import CommentViewSet +from algo_sports.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestCommentViewSet: + def test_get_querset(self): + view = CommentViewSet() + + user = UserFactory() + + rf = APIRequestFactory() + request = rf.get("/fake_url/") + request.user = user + + view.request = request + + post = PostFactory() + parent_size = 10 + CommentFactory.create_batch(parent_size, post_id=post) + assert view.get_queryset().count() == parent_size + + def test_add_recomment(self): + client = APIClient() + client2 = APIClient() + + user = UserFactory() + user2 = UserFactory() + + client.force_authenticate(user=user) + client2.force_authenticate(user=user2) + + comments = CommentFactory.create_batch(3, user_id=user) + recomment_sizes = random.sample(range(5, 30), len(comments)) + + for recomment_size, comment in zip(recomment_sizes, comments): + url = reverse("api:comment-add-recomment", kwargs={"pk": comment.pk}) + + # Comment 객체의 실제 recomment 수와 기대하는 recomment 수 비교 + for _ in range(recomment_size): + response = client.post(url, data={"content": "Hello! This is Comment"}) + assert response.status_code == status.HTTP_201_CREATED + response = client2.post(url, data={"content": "Hello! This is Comment"}) + assert response.status_code == status.HTTP_201_CREATED + + assert comment.get_childs().count() == recomment_size * 2 + + # Comment detail로 요청을 보냈을 때 오는 recomment 수와 기대하는 recomment 수 비교 + detail_url = reverse("api:comment-detail", kwargs={"pk": comment.pk}) + response = client.get(detail_url) + assert len(response.data["recomments"]) == recomment_size * 2 + + # ReRecomment 불가능하도록 막기 + recomment = comments[0].get_childs()[0] + url = reverse("api:comment-add-recomment", kwargs={"pk": recomment.pk}) + response = client.post(url, data={"content": "RereComment is not allowed!"}) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_comment_delete(self): + client = APIClient() + + user = UserFactory() + + client.force_authenticate(user=user) + comment = CommentFactory(user_id=user) + + url = reverse("api:comment-detail", kwargs={"pk": comment.pk}) + response = client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + + def test_deleted_comment_permission(self): + client = APIClient() + + user = UserFactory() + + client.force_authenticate(user=user) + comment = CommentFactory(user_id=user) + + url = reverse("api:comment-detail", kwargs={"pk": comment.pk}) + + # Delete + response = client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # 삭제 후 수정, 삭제 불가 + response = client.get(url) + assert response.status_code == status.HTTP_200_OK + + response = client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + response = client.put(url, {"content": "Are you deleted?"}) + assert response.status_code == status.HTTP_403_FORBIDDEN + + response = client.patch(url, {"content": "Are you deleted?"}) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/algo_sports/blogs/tests/test_model_methods.py b/algo_sports/blogs/tests/test_model_methods.py new file mode 100644 index 0000000..65c5f3e --- /dev/null +++ b/algo_sports/blogs/tests/test_model_methods.py @@ -0,0 +1,34 @@ +import pytest + +from algo_sports.blogs.tests.factories import BlogFactory, CommentFactory, PostFactory +from algo_sports.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestBlogAppModelMethods: + def test_blogapp(self): + blog = BlogFactory() + post = PostFactory(blog_id=blog) + comments = CommentFactory.create_batch( + 20, + post_id=post, + user_id=post.user, + ) + + for c in post.get_comments(): + assert c in comments + + comment = comments[0] + recomments = CommentFactory.create_batch( + 20, + parent_id=comment, + user_id=UserFactory(), + ) + + assert post == comment.post + assert blog == post.blog + + for c in comment.get_childs(): + assert c in recomments + assert c.parent == comment diff --git a/algo_sports/blogs/tests/test_post_viewset.py b/algo_sports/blogs/tests/test_post_viewset.py new file mode 100644 index 0000000..e970d3c --- /dev/null +++ b/algo_sports/blogs/tests/test_post_viewset.py @@ -0,0 +1,30 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from algo_sports.blogs.tests.factories import BlogFactory, PostFactory +from algo_sports.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestPostViewSet: + def test_add_comment(self): + client = APIClient() + + BlogFactory.create_batch(100) + + user = UserFactory() + + client.force_authenticate(user=user) + post = PostFactory(user_id=user) + + url = reverse("api:post-add-comment", kwargs={"pk": post.pk}) + response = client.post(url, data={"content": "Hello! This is Comment"}) + assert response.status_code == status.HTTP_201_CREATED + + user2 = UserFactory() + client.force_authenticate(user=user2) + response = client.post(url, data={"content": "Hello! This is Comment"}) + assert response.status_code == status.HTTP_201_CREATED diff --git a/algo_sports/blogs/views.py b/algo_sports/blogs/views.py new file mode 100644 index 0000000..a2e4de6 --- /dev/null +++ b/algo_sports/blogs/views.py @@ -0,0 +1,178 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.mixins import ( + DestroyModelMixin, + ListModelMixin, + RetrieveModelMixin, + UpdateModelMixin, +) +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from algo_sports.blogs.filters import BlogFilter, CommentFilter, PostFilter +from algo_sports.blogs.permissions import IsCommentNotDeleted +from algo_sports.utils.paginations import SizeQueryPagination +from algo_sports.utils.permissions import IsOwnerOrReadAndPostOnly, IsSuperUser + +from .models import Blog, Comment, Post +from .serializers import ( + BlogSerializer, + CommentSerializer, + PostSerializer, + ReCommentSerializer, +) + +User = get_user_model() + + +class BlogViewSet( + ListModelMixin, + UpdateModelMixin, + RetrieveModelMixin, + GenericViewSet, +): + """ + Blog 리스트, 업데이트, 디테일 ViewSet + + @action + add_post : 해당 블로그에 포스트 생성 + """ + + serializer_class = BlogSerializer + queryset = Blog.objects.all() + lookup_field = "category" + permission_classes = [IsAuthenticatedOrReadOnly | IsSuperUser] + filterset_class = BlogFilter + + action_serializer_classes = { + "add_post": PostSerializer, + } + + def get_queryset(self): + queryset = self.queryset.filter(permission__lte=self.request.user.level) + return queryset + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + return super().get_serializer_class() + + @action(detail=True, methods=["POST"]) + def add_post(self, request, *args, **kwargs): + blog = self.get_object() + if blog.permission <= request.user.level: + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(user_id=request.user, blog_id=blog) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(status=status.HTTP_403_FORBIDDEN) + + +class PostViewSet( + ListModelMixin, + UpdateModelMixin, + RetrieveModelMixin, + DestroyModelMixin, + GenericViewSet, +): + """ + Post 리스트, 업데이트, 디테일 ViewSet + + @action + add_comment : 해당 포스트에 댓글 생성 + """ + + serializer_class = PostSerializer + queryset = Post.objects.all() + permission_classes = [ + IsAuthenticatedOrReadOnly, + IsSuperUser | IsOwnerOrReadAndPostOnly, + ] + lookup_field = "pk" + pagination_class = SizeQueryPagination + filterset_class = PostFilter + + action_serializer_classes = { + "add_comment": CommentSerializer, + } + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + return super().get_serializer_class() + + @action(detail=True, methods=["POST"]) + def add_comment(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(user_id=request.user, post_id=self.get_object()) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class CommentViewSet( + ListModelMixin, + UpdateModelMixin, + RetrieveModelMixin, + DestroyModelMixin, + GenericViewSet, +): + """ + Comment 조회, 갱신, 삭제 + + @action + add_recomment : 해당 댓글에 대댓글 생성 + """ + + serializer_class = CommentSerializer + queryset = Comment.objects.all() + permission_classes = [ + IsAuthenticatedOrReadOnly, + IsSuperUser | IsOwnerOrReadAndPostOnly, + IsCommentNotDeleted, + ] + lookup_field = "pk" + pagination_class = SizeQueryPagination + filterset_class = CommentFilter + + action_serializer_classes = { + "add_recomment": ReCommentSerializer, + } + + def get_queryset(self): + queryset = self.queryset.filter(parent_id__isnull=True) + return queryset + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + return super().get_serializer_class() + + def destroy(self, request, *args, **kwargs): + comment = self.get_object() + # if not comment.deleted: + comment.fake_delete() + comment.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["POST"]) + def add_recomment(self, request, *args, **kwargs): + comment = self.get_object() + # 이미 부모를 가지고 있는 대댓글 같은 경우에는 자식을 가지지 못한다. + if comment.parent is not None: + return Response( + {"detail": "400 BAD REQUEST"}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save( + user_id=request.user, + parent_id=comment, + post_id=comment.post_id, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/algo_sports/codes/admin.py b/algo_sports/codes/admin.py index 3ac785d..3c6d0be 100644 --- a/algo_sports/codes/admin.py +++ b/algo_sports/codes/admin.py @@ -1,49 +1,71 @@ from django.contrib import admin -from .models import JudgementCode, UserCode +from .models import JudgementCode, MatchCodeRelation, ProgrammingLanguage, UserCode + + +@admin.register(ProgrammingLanguage) +class ProgrammingLanguageAdmin(admin.ModelAdmin): + list_display = ("id", "name") + search_fields = ("name",) + list_filter = ("is_active",) @admin.register(UserCode) class UserCodeAdmin(admin.ModelAdmin): - model = UserCode - - list_display = [ - "id", - "author", - "programming_language", - "created_at", - "updated_at", - ] - search_fields = [ - "author", - ] - list_filter = [ - "programming_language", - ] - - def author(self, obj): - return obj.user.username + # list_display = ( + # "id", + # "user_id", + # "programming_language", + # "code", + # "is_active", + # "created_at", + # "updated_at", + # ) + # list_filter = ("user_id", "programming_language", "created_at", "updated_at") + # search_fields = ["author"] + # raw_id_fields = ("gamematchs",) + # date_hierarchy = "created_at" + + # def author(self, obj): + # return obj.user.username + pass @admin.register(JudgementCode) class JudgementCodeAdmin(admin.ModelAdmin): - model = JudgementCode - - list_display = [ - "id", - "author", - "gameinfo", - "programming_language", - "created_at", - "updated_at", - ] - search_fields = [ - "author", - "gameinfo__title", - ] - list_filter = [ - "programming_language", - ] - - def author(self, obj): - return obj.user.username + # list_display = ( + # "id", + # "user_id", + # "gameinfo_id", + # "programming_language", + # "code", + # "created_at", + # "updated_at", + # ) + # list_filter = ( + # "user_id", + # "gameinfo_id", + # "programming_language", + # "created_at", + # "updated_at", + # ) + # search_fields = ("author", "gameinfo__title") + # date_hierarchy = "created_at" + + # def author(self, obj): + # return obj.user.username + pass + + +@admin.register(MatchCodeRelation) +class MatchCodeRelationAdmin(admin.ModelAdmin): + # list_display = ( + # "id", + # "usercode_id", + # "gamematch_id", + # "created_at", + # "updated_at", + # ) + # list_filter = ("usercode_id", "gamematch_id", "created_at", "updated_at") + # date_hierarchy = "created_at" + pass diff --git a/algo_sports/codes/filters.py b/algo_sports/codes/filters.py new file mode 100644 index 0000000..a95e780 --- /dev/null +++ b/algo_sports/codes/filters.py @@ -0,0 +1,28 @@ +from django_filters import rest_framework as filters +from django_filters.filters import CharFilter + +from .models import JudgementCode, UserCode + + +class UserCodeFilter(filters.FilterSet): + user = CharFilter(field_name="user_id__username") + + class Meta: + model = UserCode + fields = ( + "is_active", + "programming_language", + "gamerooms", + ) + + +class JudgementCodeFilter(filters.FilterSet): + user = CharFilter(field_name="user_id__username") + + class Meta: + model = JudgementCode + fields = ( + "user", + "gameversion_id", + "programming_language", + ) diff --git a/algo_sports/codes/fixtures/programming_language.json b/algo_sports/codes/fixtures/programming_language.json new file mode 100644 index 0000000..cec1c54 --- /dev/null +++ b/algo_sports/codes/fixtures/programming_language.json @@ -0,0 +1,480 @@ +[ + { + "model": "codes.programminglanguage", + "pk": 1, + "fields": { + "name": "Assembly (NASM 2.14.02)", + "compile_cmd": "/usr/local/nasm-2.14.02/bin/nasmld -f elf64 %s main.asm", + "run_cmd": "./a.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 2, + "fields": { + "name": "Bash (5.0.0)", + "run_cmd": "/usr/local/bash-${VERSION%.*}/bin/bash script.sh", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 3, + "fields": { + "name": "Basic (FBC 1.07.1)", + "compile_cmd": "/usr/local/fbc-1.07.1/bin/fbc %s main.bas", + "run_cmd": "./main", + "is_active": "False" + } + }, + { + "model": "codes.programminglanguage", + "pk": 4, + "fields": { + "name": "C (Clang 7.0.1)", + "compile_cmd": "/usr/bin/clang-7 main.c", + "run_cmd": "./a.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 5, + "fields": { + "name": "C (GCC 7.4.0)", + "compile_cmd": "/usr/local/gcc-7.4.0/bin/gcc", + "run_cmd": "./a.out", + "is_active": true, + "template_code": { + "main": "{% autoescape off %}\n{{ includes }}\n\n{{ solution }}\n\nint main(int argc, char const *argv[]) {\n solution({{ arguments }});\n return 0;\n}\n{% endautoescape %}\n", + "solution": "{% autoescape off %}\n#include \n\nvoid solution({{ parameters }}) {\n printf(\"Hello world\");\n}\n{% endautoescape %}\n" + }, + "extension": "c" + } + }, + { + "model": "codes.programminglanguage", + "pk": 6, + "fields": { + "name": "C (GCC 8.3.0)", + "compile_cmd": "/usr/local/gcc-8.3.0/bin/gcc", + "run_cmd": "./a.out", + "is_active": true, + "template_code": { + "main": "{% autoescape off %}\n{{ includes }}\n\n{{ solution }}\n\nint main(int argc, char const *argv[]) {\n solution({{ arguments }});\n return 0;\n}\n{% endautoescape %}\n", + "solution": "{% autoescape off %}\n#include \n\nvoid solution({{ parameters }}) {\n printf(\"Hello world\");\n}\n{% endautoescape %}\n" + }, + "extension": "c" + } + }, + { + "model": "codes.programminglanguage", + "pk": 7, + "fields": { + "name": "C (GCC 9.2.0)", + "compile_cmd": "/usr/local/gcc-9.2.0/bin/gcc", + "run_cmd": "./a.out", + "is_active": true, + "template_code": { + "main": "{% autoescape off %}\n{{ includes }}\n\n{{ solution }}\n\nint main(int argc, char const *argv[]) {\n solution({{ arguments }});\n return 0;\n}\n{% endautoescape %}\n", + "solution": "{% autoescape off %}\n#include \n\nvoid solution({{ parameters }}) {\n printf(\"Hello world\");\n}\n{% endautoescape %}\n" + }, + "extension": "c" + } + }, + { + "model": "codes.programminglanguage", + "pk": 8, + "fields": { + "name": "C# (Mono 6.6.0.161)", + "compile_cmd": "/usr/local/mono-6.6.0.161/bin/mcs %s Main.cs", + "run_cmd": "/usr/local/mono-6.6.0.161/bin/mono Main.exe", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 9, + "fields": { + "name": "C++ (Clang 7.0.1)", + "compile_cmd": "/usr/bin/clang++-7", + "run_cmd": "./a.out", + "is_active": true, + "template_code": { + "main": "{% autoescape off %}\n{{ includes }}\n\n{{ solution }}\n\nint main(int argc, char const *argv[]) {\n solution({{ arguments }});\n return 0;\n}\n{% endautoescape %}\n", + "solution": "{% autoescape off %}\n#include \n\nvoid solution({{ parameters }}) {\n printf(\"Hello world\");\n}\n{% endautoescape %}\n" + }, + "extension": "cpp" + } + }, + { + "model": "codes.programminglanguage", + "pk": 10, + "fields": { + "name": "C++ (GCC 7.4.0)", + "compile_cmd": "/usr/local/gcc-7.4.0/bin/g++ %s main.cpp", + "run_cmd": "LD_LIBRARY_PATH=/usr/local/gcc-7.4.0/lib64 ./a.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 11, + "fields": { + "name": "C++ (GCC 8.3.0)", + "compile_cmd": "/usr/local/gcc-8.3.0/bin/g++ %s main.cpp", + "run_cmd": "LD_LIBRARY_PATH=/usr/local/gcc-8.3.0/lib64 ./a.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 12, + "fields": { + "name": "C++ (GCC 9.2.0)", + "compile_cmd": "/usr/local/gcc-9.2.0/bin/g++", + "run_cmd": "LD_LIBRARY_PATH=/usr/local/gcc-9.2.0/lib64 ./", + "is_active": true, + "template_code": { + "main": "{% autoescape off %}\n{{ includes }}\n\n{{ solution }}\n\nint main(int argc, char const *argv[]) {\n solution({{ arguments }});\n return 0;\n}\n{% endautoescape %}\n", + "solution": "{% autoescape off %}\n#include \n\nvoid solution({{ parameters }}) {\n printf(\"Hello world\");\n}\n{% endautoescape %}\n" + }, + "extension": "cpp" + } + }, + { + "model": "codes.programminglanguage", + "pk": 13, + "fields": { + "name": "Clojure (1.10.1)", + "run_cmd": "/usr/local/bin/java -jar /usr/local/clojure-1.10.1/clojure.jar main.clj", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 14, + "fields": { + "name": "COBOL (GnuCOBOL 2.2)", + "compile_cmd": "/usr/local/gnucobol-2.2/bin/cobc -free -x %s main.cob", + "run_cmd": "LD_LIBRARY_PATH=/usr/local/gnucobol-2.2/lib ./main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 15, + "fields": { + "name": "Common Lisp (SBCL 2.0.0)", + "run_cmd": "SBCL_HOME=/usr/local/sbcl-2.0.0/lib/sbcl /usr/local/sbcl-2.0.0/bin/sbcl --script script.lisp", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 16, + "fields": { + "name": "D (DMD 2.089.1)", + "compile_cmd": "/usr/local/d-2.089.1/linux/bin64/dmd %s main.d", + "run_cmd": "./main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 17, + "fields": { + "name": "Elixir (1.9.4)", + "run_cmd": "/usr/local/elixir-1.9.4/bin/elixir script.exs", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 18, + "fields": { + "name": "Erlang (OTP 22.2)", + "run_cmd": "/bin/sed -i '1s/^/\\\\n/' main.erl && /usr/local/erlang-22.2/bin/escript main.erl", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 19, + "fields": { + "name": "Executable", + "run_cmd": "/bin/chmod +x a.out && ./a.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 20, + "fields": { + "name": "F# (.NET Core SDK 3.1.202)", + "run_cmd": "mkdir -p ~/.dotnet && touch ~/.dotnet/3.1.202.dotnetFirstUseSentinel && /usr/local/dotnet-sdk/dotnet fsi script.fsx", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 21, + "fields": { + "name": "Fortran (GFortran 9.2.0)", + "compile_cmd": "/usr/local/gcc-9.2.0/bin/gfortran %s main.f90", + "run_cmd": "LD_LIBRARY_PATH=/usr/local/gcc-9.2.0/lib64 ./a.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 22, + "fields": { + "name": "Go (1.13.5)", + "compile_cmd": "GOCACHE=/tmp/.cache/go-build /usr/local/go-1.13.5/bin/go build %s main.go", + "run_cmd": "./main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 23, + "fields": { + "name": "Groovy (3.0.3)", + "compile_cmd": "/usr/local/groovy-3.0.3/bin/groovyc %s script.groovy", + "run_cmd": "/usr/local/bin/java -cp \\\".:/usr/local/groovy-3.0.3/lib/*\\\" script", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 24, + "fields": { + "name": "Haskell (GHC 8.8.1)", + "compile_cmd": "/usr/local/ghc-8.8.1/bin/ghc %s main.hs", + "run_cmd": "./main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 25, + "fields": { + "name": "Java (OpenJDK 13.0.1)", + "compile_cmd": "/usr/local/openjdk${VERSION%%.*}/bin/javac %s Main.java", + "run_cmd": "/usr/local/openjdk${VERSION%%.*}/bin/java ${BINARY_FILE%.*}", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 26, + "fields": { + "name": "JavaScript (Node.js 12.14.0)", + "run_cmd": "/usr/local/node-12.14.0/bin/node", + "is_active": true, + "extension": "js" + } + }, + { + "model": "codes.programminglanguage", + "pk": 27, + "fields": { + "name": "Kotlin (1.3.70)", + "compile_cmd": "/usr/local/kotlin-1.3.70/bin/kotlinc %s Main.kt", + "run_cmd": "/usr/local/kotlin-1.3.70/bin/kotlin MainKt", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 28, + "fields": { + "name": "Lua (5.3.5)", + "compile_cmd": "/usr/local/lua-5.3.5/luac53 %s script.lua", + "run_cmd": "/usr/local/lua-5.3.5/lua53 ./luac.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 29, + "fields": { + "name": "Objective-C (Clang 7.0.1)", + "compile_cmd": "/usr/bin/clang-7 `gnustep-config --objc-flags | sed 's/-W[^ ]* //g'` `gnustep-config --base-libs | sed 's/-shared-libgcc//'` -I/usr/lib/gcc/x86_64-linux-gnu/8/include main.m %s", + "run_cmd": "./a.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 30, + "fields": { + "name": "OCaml (4.09.0)", + "compile_cmd": "/usr/local/ocaml-4.09.0/bin/ocamlc %s main.ml", + "run_cmd": "./a.out", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 31, + "fields": { + "name": "Octave (5.1.0)", + "run_cmd": "/usr/local/octave-5.1.0/bin/octave-cli -q --no-gui --no-history script.m", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 32, + "fields": { + "name": "Pascal (FPC 3.0.4)", + "compile_cmd": "/usr/local/fpc-3.0.4/bin/fpc %s main.pas", + "run_cmd": "./main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 33, + "fields": { + "name": "Perl (5.28.1)", + "run_cmd": "/usr/bin/perl script.pl", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 34, + "fields": { + "name": "PHP (7.4.1)", + "run_cmd": "/usr/local/php-7.4.1/bin/php script.php", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 35, + "fields": { + "name": "Plain Text", + "run_cmd": "/bin/cat text.txt", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 36, + "fields": { + "name": "Prolog (GNU Prolog 1.4.5)", + "compile_cmd": "PATH=\\\"/usr/local/gprolog-1.4.5/gprolog-1.4.5/bin:\\$PATH\\\" /usr/local/gprolog-1.4.5/gprolog-1.4.5/bin/gplc --no-top-level %s main.pro", + "run_cmd": "./main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 37, + "fields": { + "name": "Python (2.7.17)", + "run_cmd": "/usr/local/python-2.7.17/bin/python2", + "is_active": true, + "template_code": { + "main": "{% autoescape off %}\n{{ includes }}\n\n{{ solution }}\n\nint main(int argc, char const *argv[]) {\n solution({{ arguments }});\n return 0;\n}\n{% endautoescape %}\n", + "solution": "{% autoescape off %}\n#include \n\nvoid solution({{ parameters }}) {\n printf(\"Hello world\");\n}\n{% endautoescape %}\n" + }, + "extension": "py" + } + }, + { + "model": "codes.programminglanguage", + "pk": 38, + "fields": { + "name": "Python (3.8.1)", + "run_cmd": "/usr/local/python-3.8.1/bin/python3", + "is_active": true, + "template_code": { + "main": "{% autoescape off %}import sys\n{{ includes }}\n\n{{ solution }}\n\nif __name__ == \"__main__\":\n argv = sys.argv\n solution({{ arguments }})\n{% endautoescape %}\n", + "solution": "{% autoescape off %}def solution({{ parameters }}):\n print(\"Hello\")\n{% endautoescape %}\n" + }, + "extension": "py" + } + }, + { + "model": "codes.programminglanguage", + "pk": 39, + "fields": { + "name": "R (4.0.0)", + "run_cmd": "/usr/local/r-4.0.0/bin/Rscript script.r", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 40, + "fields": { + "name": "Ruby (2.7.0)", + "run_cmd": "/usr/local/ruby-2.7.0/bin/ruby script.rb", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 41, + "fields": { + "name": "Rust (1.40.0)", + "compile_cmd": "/usr/local/rust-1.40.0/bin/rustc %s main.rs", + "run_cmd": "./main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 42, + "fields": { + "name": "Scala (2.13.2)", + "compile_cmd": "/usr/local/scala-2.13.2/bin/scalac %s Main.scala", + "run_cmd": "/usr/local/scala-2.13.2/bin/scala Main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 43, + "fields": { + "name": "SQL (SQLite 3.27.2)", + "run_cmd": "/bin/cat script.sql | /usr/bin/sqlite3 db.sqlite", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 44, + "fields": { + "name": "Swift (5.2.3)", + "compile_cmd": "/usr/local/swift-5.2.3/bin/swiftc %s Main.swift", + "run_cmd": "./Main", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 45, + "fields": { + "name": "TypeScript (3.7.4)", + "compile_cmd": "/usr/bin/tsc %s script.ts", + "run_cmd": "/usr/local/node-12.14.0/bin/node script.js", + "is_active": false + } + }, + { + "model": "codes.programminglanguage", + "pk": 46, + "fields": { + "name": "Visual Basic.Net (vbnc 0.0.0.5943)", + "compile_cmd": "/usr/bin/vbnc %s Main.vb", + "run_cmd": "/usr/bin/mono Main.exe" + } + } +] diff --git a/algo_sports/codes/fixtures/template_codes/C++/main b/algo_sports/codes/fixtures/template_codes/C++/main new file mode 100644 index 0000000..4b1d68e --- /dev/null +++ b/algo_sports/codes/fixtures/template_codes/C++/main @@ -0,0 +1,10 @@ +{% autoescape off %} +{{ includes }} + +{{ solution }} + +int main(int argc, char const *argv[]) { + solution({{ arguments }}); + return 0; +} +{% endautoescape %} diff --git a/algo_sports/codes/fixtures/template_codes/C++/solution b/algo_sports/codes/fixtures/template_codes/C++/solution new file mode 100644 index 0000000..c0624ba --- /dev/null +++ b/algo_sports/codes/fixtures/template_codes/C++/solution @@ -0,0 +1,7 @@ +{% autoescape off %} +#include + +void solution({{ parameters }}) { + printf("Hello world"); +} +{% endautoescape %} diff --git a/algo_sports/codes/fixtures/template_codes/C/main b/algo_sports/codes/fixtures/template_codes/C/main new file mode 100644 index 0000000..4b1d68e --- /dev/null +++ b/algo_sports/codes/fixtures/template_codes/C/main @@ -0,0 +1,10 @@ +{% autoescape off %} +{{ includes }} + +{{ solution }} + +int main(int argc, char const *argv[]) { + solution({{ arguments }}); + return 0; +} +{% endautoescape %} diff --git a/algo_sports/codes/fixtures/template_codes/C/solution b/algo_sports/codes/fixtures/template_codes/C/solution new file mode 100644 index 0000000..a069b49 --- /dev/null +++ b/algo_sports/codes/fixtures/template_codes/C/solution @@ -0,0 +1,8 @@ +{% autoescape off %} +#include +#include + +void solution({{ parameters }}) { + printf("Hello world"); +} +{% endautoescape %} diff --git a/algo_sports/codes/fixtures/template_codes/Python2/main b/algo_sports/codes/fixtures/template_codes/Python2/main new file mode 100644 index 0000000..0f0533b --- /dev/null +++ b/algo_sports/codes/fixtures/template_codes/Python2/main @@ -0,0 +1,10 @@ +{% autoescape off %} +import sys +{{ includes }} + +{{ solution }} + +if __name__ == "__main__": + argv = sys.argv + solution({{ arguments }}) +{% endautoescape %} diff --git a/algo_sports/codes/fixtures/template_codes/Python2/solution b/algo_sports/codes/fixtures/template_codes/Python2/solution new file mode 100644 index 0000000..22adcb4 --- /dev/null +++ b/algo_sports/codes/fixtures/template_codes/Python2/solution @@ -0,0 +1,4 @@ +{% autoescape off %} +def solution({{ parameters }}): + print "Hello" +{% endautoescape %} diff --git a/algo_sports/codes/fixtures/template_codes/Python3/main b/algo_sports/codes/fixtures/template_codes/Python3/main new file mode 100644 index 0000000..0f0533b --- /dev/null +++ b/algo_sports/codes/fixtures/template_codes/Python3/main @@ -0,0 +1,10 @@ +{% autoescape off %} +import sys +{{ includes }} + +{{ solution }} + +if __name__ == "__main__": + argv = sys.argv + solution({{ arguments }}) +{% endautoescape %} diff --git a/algo_sports/codes/fixtures/template_codes/Python3/solution b/algo_sports/codes/fixtures/template_codes/Python3/solution new file mode 100644 index 0000000..63ea529 --- /dev/null +++ b/algo_sports/codes/fixtures/template_codes/Python3/solution @@ -0,0 +1,4 @@ +{% autoescape off %} +def solution({{ parameters }}): + print("Hello") +{% endautoescape %} diff --git a/algo_sports/codes/migrations/0001_initial.py b/algo_sports/codes/migrations/0001_initial.py index db049be..153340e 100644 --- a/algo_sports/codes/migrations/0001_initial.py +++ b/algo_sports/codes/migrations/0001_initial.py @@ -1,7 +1,6 @@ -# Generated by Django 3.0.10 on 2020-11-13 01:18 +# Generated by Django 3.1.3 on 2020-11-20 17:25 from django.conf import settings -import django.contrib.postgres.fields.jsonb from django.db import migrations, models import django.db.models.deletion @@ -11,96 +10,52 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("games", "0001_initial"), + ('games', '0002_auto_20201120_1723'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name="UserCode", + name='CodeRoomRelation', fields=[ - ("usercode_id", models.AutoField(primary_key=True, serialize=False)), - ( - "programming_language", - models.CharField( - max_length=30, verbose_name="Programming language" - ), - ), - ("code", models.TextField(verbose_name="Submitted code")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "user_id", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.IntegerField(verbose_name='Game score')), + ('history', models.JSONField(verbose_name='Game history')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ('gameroom_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='games.gameroom')), ], + options={ + 'db_table': 'code_room_relation', + }, ), migrations.CreateModel( - name="JudgementCode", + name='UserCode', fields=[ - ( - "judgementcode_id", - models.AutoField(primary_key=True, serialize=False), - ), - ( - "programming_language", - models.CharField( - max_length=30, verbose_name="Programming language" - ), - ), - ("code", models.TextField(verbose_name="Submitted code")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "gameinfo_id", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="judgement_codes", - to="games.GameInfo", - verbose_name="Game information", - ), - ), - ( - "user_id", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to=settings.AUTH_USER_MODEL, - ), - ), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('programming_language', models.CharField(max_length=30, verbose_name='Programming language')), + ('code', models.TextField(verbose_name='Submitted code')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('gamerooms', models.ManyToManyField(related_name='usercodes', through='codes.CodeRoomRelation', to='games.GameRoom')), + ('user_id', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( - name="GameCodes", + name='JudgementCode', fields=[ - ("gamecodes_id", models.AutoField(primary_key=True, serialize=False)), - ("score", models.IntegerField(verbose_name="Game score")), - ( - "history", - django.contrib.postgres.fields.jsonb.JSONField( - verbose_name="Game history" - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("finished_at", models.DateTimeField(blank=True, null=True)), - ( - "gameroom_id", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="games.GameRoom" - ), - ), - ( - "usercode_id", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="codes.UserCode" - ), - ), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('programming_language', models.CharField(max_length=30, verbose_name='Programming language')), + ('code', models.TextField(verbose_name='Submitted code')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('gameinfo_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='judgement_codes', to='games.gameinfo', verbose_name='Game information')), + ('user_id', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ], ), + migrations.AddField( + model_name='coderoomrelation', + name='usercode_id', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='codes.usercode'), + ), ] diff --git a/algo_sports/codes/migrations/0002_add_programming_language_table.py b/algo_sports/codes/migrations/0002_add_programming_language_table.py new file mode 100644 index 0000000..d9c1d6f --- /dev/null +++ b/algo_sports/codes/migrations/0002_add_programming_language_table.py @@ -0,0 +1,31 @@ +# Generated by Django 3.1.3 on 2020-11-20 18:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('codes', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ProgrammingLanguage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.SlugField(unique=True, verbose_name='Programming language')), + ], + ), + migrations.AlterField( + model_name='judgementcode', + name='programming_language', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='codes.programminglanguage', verbose_name='Programming language'), + ), + migrations.AlterField( + model_name='usercode', + name='programming_language', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='codes.programminglanguage', verbose_name='Programming language'), + ), + ] diff --git a/algo_sports/codes/migrations/0003_usercode_is_active.py b/algo_sports/codes/migrations/0003_usercode_is_active.py new file mode 100644 index 0000000..f19d6ef --- /dev/null +++ b/algo_sports/codes/migrations/0003_usercode_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.3 on 2020-11-25 00:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('codes', '0002_add_programming_language_table'), + ] + + operations = [ + migrations.AddField( + model_name='usercode', + name='is_active', + field=models.BooleanField(default=False, verbose_name='Is code active?'), + ), + ] diff --git a/algo_sports/codes/migrations/0004_auto_20201203_2123.py b/algo_sports/codes/migrations/0004_auto_20201203_2123.py new file mode 100644 index 0000000..5aaea92 --- /dev/null +++ b/algo_sports/codes/migrations/0004_auto_20201203_2123.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.3 on 2020-12-03 21:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('codes', '0003_usercode_is_active'), + ] + + operations = [ + migrations.AlterField( + model_name='coderoomrelation', + name='history', + field=models.JSONField(default=dict, verbose_name='Game history'), + ), + migrations.AlterField( + model_name='coderoomrelation', + name='score', + field=models.IntegerField(default=0, verbose_name='Game score'), + ), + ] diff --git a/algo_sports/codes/migrations/0005_auto_20201205_0015.py b/algo_sports/codes/migrations/0005_auto_20201205_0015.py new file mode 100644 index 0000000..4148697 --- /dev/null +++ b/algo_sports/codes/migrations/0005_auto_20201205_0015.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.3 on 2020-12-05 00:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('codes', '0004_auto_20201203_2123'), + ] + + operations = [ + migrations.AddField( + model_name='programminglanguage', + name='compile_cmd', + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AddField( + model_name='programminglanguage', + name='is_active', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='programminglanguage', + name='run_cmd', + field=models.CharField(blank=True, max_length=500, null=True), + ), + ] diff --git a/algo_sports/codes/migrations/0006_auto_20201205_0158.py b/algo_sports/codes/migrations/0006_auto_20201205_0158.py new file mode 100644 index 0000000..bc2de78 --- /dev/null +++ b/algo_sports/codes/migrations/0006_auto_20201205_0158.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.3 on 2020-12-05 01:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0005_auto_20201205_0158'), + ('codes', '0005_auto_20201205_0015'), + ] + + operations = [ + migrations.CreateModel( + name='MatchCodeRelation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(blank=True, null=True)), + ('gamematch_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='games.gamematch')), + ], + options={ + 'db_table': 'match_code_relation', + }, + ), + migrations.RemoveField( + model_name='usercode', + name='gamerooms', + ), + migrations.DeleteModel( + name='CodeRoomRelation', + ), + migrations.AddField( + model_name='matchcoderelation', + name='usercode_id', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='codes.usercode'), + ), + migrations.AddField( + model_name='usercode', + name='gamematchs', + field=models.ManyToManyField(related_name='usercodes', through='codes.MatchCodeRelation', to='games.GameMatch'), + ), + ] diff --git a/algo_sports/codes/migrations/0007_usercode_gameroom.py b/algo_sports/codes/migrations/0007_usercode_gameroom.py new file mode 100644 index 0000000..8f58a0e --- /dev/null +++ b/algo_sports/codes/migrations/0007_usercode_gameroom.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.3 on 2020-12-06 23:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0005_auto_20201205_0158'), + ('codes', '0006_auto_20201205_0158'), + ] + + operations = [ + migrations.AddField( + model_name='usercode', + name='gameroom', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='usercodes', to='games.gameroom'), + preserve_default=False, + ), + ] diff --git a/algo_sports/codes/migrations/0008_auto_20201207_1401.py b/algo_sports/codes/migrations/0008_auto_20201207_1401.py new file mode 100644 index 0000000..bdd6d1a --- /dev/null +++ b/algo_sports/codes/migrations/0008_auto_20201207_1401.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.3 on 2020-12-07 14:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0005_auto_20201205_0158'), + ('codes', '0007_usercode_gameroom'), + ] + + operations = [ + migrations.RemoveField( + model_name='judgementcode', + name='gameinfo_id', + ), + migrations.AddField( + model_name='judgementcode', + name='gameversion_id', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='judgement_codes', to='games.gameversion', verbose_name='Game version'), + preserve_default=False, + ), + migrations.AlterField( + model_name='usercode', + name='is_active', + field=models.BooleanField(default=True, verbose_name='Is code active?'), + ), + ] diff --git a/algo_sports/codes/migrations/0009_programminglanguage_template_code.py b/algo_sports/codes/migrations/0009_programminglanguage_template_code.py new file mode 100644 index 0000000..36403bd --- /dev/null +++ b/algo_sports/codes/migrations/0009_programminglanguage_template_code.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.3 on 2020-12-11 23:26 + +import algo_sports.codes.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('codes', '0008_auto_20201207_1401'), + ] + + operations = [ + migrations.AddField( + model_name='programminglanguage', + name='template_code', + field=models.JSONField(default=algo_sports.codes.models.make_template_code), + ), + ] diff --git a/algo_sports/codes/migrations/0010_auto_20201212_0051.py b/algo_sports/codes/migrations/0010_auto_20201212_0051.py new file mode 100644 index 0000000..3398215 --- /dev/null +++ b/algo_sports/codes/migrations/0010_auto_20201212_0051.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.3 on 2020-12-12 00:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('codes', '0009_programminglanguage_template_code'), + ] + + operations = [ + migrations.AlterField( + model_name='programminglanguage', + name='name', + field=models.CharField(max_length=50, unique=True, verbose_name='Programming language'), + ), + ] diff --git a/algo_sports/codes/migrations/0011_auto_20201213_1433.py b/algo_sports/codes/migrations/0011_auto_20201213_1433.py new file mode 100644 index 0000000..16cd894 --- /dev/null +++ b/algo_sports/codes/migrations/0011_auto_20201213_1433.py @@ -0,0 +1,42 @@ +# Generated by Django 3.1.3 on 2020-12-13 14:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0007_auto_20201213_1433'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('codes', '0010_auto_20201212_0051'), + ] + + operations = [ + migrations.RenameField( + model_name='usercode', + old_name='gamematchs', + new_name='gamematches', + ), + migrations.RenameField( + model_name='usercode', + old_name='gameroom', + new_name='gamerooms', + ), + migrations.AlterField( + model_name='judgementcode', + name='gameversion_id', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='judgementcodes', to='games.gameversion', verbose_name='Game version'), + ), + migrations.AlterField( + model_name='judgementcode', + name='user_id', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='judgementcodes', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='usercode', + name='user_id', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='usercodes', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/algo_sports/codes/migrations/0012_programminglanguage_extension.py b/algo_sports/codes/migrations/0012_programminglanguage_extension.py new file mode 100644 index 0000000..f0703f0 --- /dev/null +++ b/algo_sports/codes/migrations/0012_programminglanguage_extension.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.3 on 2020-12-13 16:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('codes', '0011_auto_20201213_1433'), + ] + + operations = [ + migrations.AddField( + model_name='programminglanguage', + name='extension', + field=models.CharField(default='py', max_length=10, verbose_name='Language extension'), + preserve_default=False, + ), + ] diff --git a/algo_sports/codes/migrations/0013_auto_20201215_0016.py b/algo_sports/codes/migrations/0013_auto_20201215_0016.py new file mode 100644 index 0000000..c196510 --- /dev/null +++ b/algo_sports/codes/migrations/0013_auto_20201215_0016.py @@ -0,0 +1,20 @@ +# Generated by Django 3.1.3 on 2020-12-15 00:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0007_auto_20201213_1433'), + ('codes', '0012_programminglanguage_extension'), + ] + + operations = [ + migrations.AlterField( + model_name='usercode', + name='gamerooms', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='usercodes', to='games.gameroom'), + ), + ] diff --git a/algo_sports/codes/models.py b/algo_sports/codes/models.py index 10d7987..a985340 100644 --- a/algo_sports/codes/models.py +++ b/algo_sports/codes/models.py @@ -1,29 +1,84 @@ from django.contrib.auth import get_user_model -from django.contrib.postgres.fields.jsonb import JSONField from django.db import models +from django.template.base import Template +from django.template.context import Context from django.utils.translation import gettext_lazy as _ -from algo_sports.games.models import GameInfo, GameRoom +from algo_sports.games.models import GameMatch, GameRoom, GameVersion User = get_user_model() +def make_template_code(main="", solution=""): + return {"main": main, "solution": solution} + + +class ProgrammingLanguage(models.Model): + name = models.CharField(_("Programming language"), max_length=50, unique=True) + is_active = models.BooleanField(default=False) + + compile_cmd = models.CharField(null=True, blank=True, max_length=500) + run_cmd = models.CharField(null=True, blank=True, max_length=500) + + template_code = models.JSONField(default=make_template_code) + extension = models.CharField(_("Language extension"), max_length=10) + + def __str__(self) -> str: + return f"{self.name}" + + @classmethod + def active_languages(cls): + return cls.objects.filter(is_active=True) + + def get_solution_template(self, parameters: list([str])): + parameters = ", ".join(parameters) + + raw_template = self.template_code.get("solution") + template = Template(raw_template) + context = Context({"parameters": parameters}) + template_code = template.render(context) + return template_code + + def get_main_template( + self, include: list([str]), arguments: list([str]), solution: str + ): + includes = "\n".join(include) + arguments = ", ".join(arguments) + + raw_template = self.template_code.get("main") + template = Template(raw_template) + context = Context( + {"includes": includes, "arguments": arguments, "solution": solution} + ) + template_code = template.render(context) + return template_code + + class UserCode(models.Model): """Code as User""" - usercode_id = models.AutoField(primary_key=True) - user_id = models.ForeignKey(User, blank=True, null=True, on_delete=models.SET_NULL) + user_id = models.ForeignKey( + User, related_name="usercodes", blank=True, null=True, on_delete=models.SET_NULL + ) + gamerooms = models.ForeignKey( + GameRoom, related_name="usercodes", null=True, on_delete=models.PROTECT + ) + gamematches = models.ManyToManyField( + GameMatch, related_name="usercodes", through="MatchCodeRelation" + ) - programming_language = models.CharField(_("Programming language"), max_length=30) + programming_language = models.ForeignKey( + ProgrammingLanguage, + verbose_name=_("Programming language"), + on_delete=models.PROTECT, + ) code = models.TextField(_("Submitted code")) + is_active = models.BooleanField(_("Is code active?"), default=True) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - @property - def id(self): - return self.usercode_id - @property def user(self): return self.user_id @@ -32,55 +87,55 @@ def user(self): class JudgementCode(models.Model): """Code as Judger""" - judgementcode_id = models.AutoField(primary_key=True) - user_id = models.ForeignKey(User, blank=True, null=True, on_delete=models.SET_NULL) - gameinfo_id = models.ForeignKey( - GameInfo, - verbose_name="Game information", + user_id = models.ForeignKey( + User, + related_name="judgementcodes", + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + gameversion_id = models.ForeignKey( + GameVersion, + verbose_name=_("Game version"), on_delete=models.PROTECT, - related_name="judgement_codes", + related_name="judgementcodes", ) - programming_language = models.CharField(_("Programming language"), max_length=30) + programming_language = models.ForeignKey( + ProgrammingLanguage, + verbose_name=_("Programming language"), + on_delete=models.PROTECT, + ) code = models.TextField(_("Submitted code")) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - @property - def id(self): - return self.judgementcode_id - @property def user(self): - return self.gameinfo_id + return self.gameversion_id @property - def gameinfo(self): - return self.gameinfo_id + def gameversion(self): + return self.gameversion_id -class GameCodes(models.Model): - """ManyToMany Through Model for GameInfo and UserCode""" +class MatchCodeRelation(models.Model): + """ManyToMany Through Model for GameMatch and UserCode""" - gamecodes_id = models.AutoField(primary_key=True) usercode_id = models.ForeignKey(UserCode, on_delete=models.PROTECT) - gameroom_id = models.ForeignKey(GameRoom, on_delete=models.PROTECT) - - score = models.IntegerField(_("Game score")) - history = JSONField(_("Game history")) + gamematch_id = models.ForeignKey(GameMatch, on_delete=models.PROTECT) created_at = models.DateTimeField(auto_now_add=True) - finished_at = models.DateTimeField(blank=True, null=True) + updated_at = models.DateTimeField(blank=True, null=True) - @property - def id(self): - return self.gamecodes_id + class Meta: + db_table = "match_code_relation" @property def usercode(self): return self.usercode_id @property - def gameroom(self): - return self.gameroom_id + def gamematch(self): + return self.gamematch_id diff --git a/algo_sports/codes/serializers.py b/algo_sports/codes/serializers.py new file mode 100644 index 0000000..ab68baf --- /dev/null +++ b/algo_sports/codes/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers + +from algo_sports.users.serializers import UsernameSerializer + +from .models import JudgementCode, MatchCodeRelation, ProgrammingLanguage, UserCode + + +class ProgrammingLanguageSerializer(serializers.ModelSerializer): + class Meta: + model = ProgrammingLanguage + exclude = ["compile_cmd", "run_cmd", "template_code"] + + +class ProgrammingLanugaeTemplateSerializer(serializers.Serializer): + parameters = serializers.ListField( + child=serializers.CharField(required=False), + write_only=True, + required=False, + ) + template_code = serializers.CharField(read_only=True) + + +class UserCodeSerializer(serializers.ModelSerializer): + user = UsernameSerializer(source="user_id", read_only=True) + + class Meta: + model = UserCode + exclude = ["user_id"] + read_only_fields = ["is_active"] + + +class JudgementCodeSerializer(serializers.ModelSerializer): + user = UsernameSerializer(source="user_id", read_only=True) + + class Meta: + model = JudgementCode + exclude = ["user_id"] + + +class MatchCodeRelationSerializer(serializers.ModelSerializer): + class Meta: + model = MatchCodeRelation + fields = [ + "id", + "usercode", + "gamematch", + "created_at", + "updated_at", + ] diff --git a/algo_sports/codes/tests/factories.py b/algo_sports/codes/tests/factories.py index c8b23f3..9762bc8 100644 --- a/algo_sports/codes/tests/factories.py +++ b/algo_sports/codes/tests/factories.py @@ -1,17 +1,28 @@ -from factory import Faker +from factory import Faker, Sequence, fuzzy from factory.declarations import SubFactory from factory.django import DjangoModelFactory -from algo_sports.codes.models import JudgementCode, UserCode -from algo_sports.games.tests.factories import GameInfoFactory +from algo_sports.codes.models import JudgementCode, ProgrammingLanguage, UserCode +from algo_sports.games.tests.factories import GameRoomFactory, GameVersionFactory from algo_sports.users.tests.factories import UserFactory +fake_word = Faker("word") + + +class ProgrammingLanguageFactory(DjangoModelFactory): + name = Sequence(lambda x: f"{fake_word.generate()} {x}") + + class Meta: + model = ProgrammingLanguage + class UserCodeFactory(DjangoModelFactory): user_id = SubFactory(UserFactory) + gamerooms = SubFactory(GameRoomFactory) - programming_language = Faker("programming_language") - code = Faker("code") + programming_language = SubFactory(ProgrammingLanguageFactory) + code = Faker("sentence") + is_active = fuzzy.FuzzyInteger(0, 1) class Meta: model = UserCode @@ -19,10 +30,10 @@ class Meta: class JudgementCodeFactory(DjangoModelFactory): user_id = SubFactory(UserFactory) - gameinfo_id = SubFactory(GameInfoFactory) + gameversion_id = SubFactory(GameVersionFactory) - programming_language = Faker("programming_language") - code = Faker("code") + programming_language = SubFactory(ProgrammingLanguageFactory) + code = Faker("sentence") class Meta: model = JudgementCode diff --git a/algo_sports/codes/tests/test_code_urls.py b/algo_sports/codes/tests/test_code_urls.py new file mode 100644 index 0000000..e177814 --- /dev/null +++ b/algo_sports/codes/tests/test_code_urls.py @@ -0,0 +1,49 @@ +import pytest +from django.urls import resolve, reverse + +from algo_sports.codes.tests.factories import JudgementCodeFactory, UserCodeFactory +from algo_sports.utils.test.compare_url import compare_url + +pytestmark = pytest.mark.django_db + + +def test_usercode_url(): + usercode = UserCodeFactory() + lookup_field = "pk" + lookup_value = usercode.pk + middle_url = "codes/user" + + # reverse url + assert compare_url( + reverse("api:usercode-detail", kwargs={lookup_field: lookup_value}), + f"/api/{middle_url}/{lookup_value}/", + ) + assert compare_url(reverse("api:usercode-list"), f"/api/{middle_url}/") + + # resolve view name + assert ( + resolve(f"/api/{middle_url}/{lookup_value}/").view_name == "api:usercode-detail" + ) + assert resolve(f"/api/{middle_url}/").view_name == "api:usercode-list" + + +def test_judgementcode_url(): + judgementcode = JudgementCodeFactory() + lookup_field = "pk" + lookup_value = judgementcode.pk + middle_url = "codes/judegement" + + # reverse url + assert compare_url( + reverse("api:judgementcode-detail", kwargs={lookup_field: lookup_value}), + f"/api/{middle_url}/{lookup_value}/", + ) + assert compare_url(reverse("api:judgementcode-list"), f"/api/{middle_url}/") + + # resolve view name + assert ( + resolve(f"/api/{middle_url}/{lookup_value}/").view_name + == "api:judgementcode-detail" + ) + + assert resolve(f"/api/{middle_url}/").view_name == "api:judgementcode-list" diff --git a/algo_sports/codes/tests/test_judgementcode_viewset.py b/algo_sports/codes/tests/test_judgementcode_viewset.py new file mode 100644 index 0000000..1ea38e1 --- /dev/null +++ b/algo_sports/codes/tests/test_judgementcode_viewset.py @@ -0,0 +1,52 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from algo_sports.codes.tests.factories import ( + JudgementCodeFactory, + ProgrammingLanguageFactory, +) +from algo_sports.games.tests.factories import GameInfoFactory +from algo_sports.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestJudgementCodeViewSet: + def test_judgementcode_permission(self): + client = APIClient() + + users = [ + UserFactory(), + UserFactory(is_staff=True), + UserFactory(is_superuser=True), + ] + + for user in users[:1]: + client.force_authenticate(user=user) + list_url = reverse("api:judgementcode-list") + + gameinfo = GameInfoFactory() + language = ProgrammingLanguageFactory() + + response = client.post( + list_url, + { + "gameinfo_id": gameinfo.id, + "programming_language": language.pk, + "code": "adfsadfasdf", + }, + ) + if user.is_admin: + assert response.status_code == status.HTTP_201_CREATED + else: + assert response.status_code == status.HTTP_403_FORBIDDEN + + judgementcode = JudgementCodeFactory() + + detail_url = reverse( + "api:judgementcode-detail", kwargs={"pk": judgementcode.pk} + ) + response = client.get(detail_url, {"code": "Hello world!"}) + assert response.status_code == status.HTTP_200_OK diff --git a/algo_sports/codes/tests/test_usercode_viewset.py b/algo_sports/codes/tests/test_usercode_viewset.py new file mode 100644 index 0000000..e36281b --- /dev/null +++ b/algo_sports/codes/tests/test_usercode_viewset.py @@ -0,0 +1,63 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient, APIRequestFactory + +from algo_sports.codes.models import UserCode +from algo_sports.codes.tests.factories import UserCodeFactory +from algo_sports.codes.views import UserCodeViewSet +from algo_sports.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestUserCodeViewSet: + def test_get_queryset(self): + # 기본 세팅 + view = UserCodeViewSet() + + rf = APIRequestFactory() + request = rf.get("/fake-url/") + + users = [ + UserFactory(), + UserFactory(), + ] + + for user in users: + usercodes = UserCodeFactory.create_batch(100, user_id=user) + request.user = user + view.request = request + assert view.get_queryset().count() == len(usercodes) + + def test_usercode_permission(self): + client = APIClient() + + users = [ + UserFactory(), + UserFactory(is_staff=True), + UserFactory(is_superuser=True), + ] + + UserCodeFactory.create_batch(30, user_id=users[0]) + UserCodeFactory.create_batch(30, user_id=users[1]) + UserCodeFactory.create_batch(30, user_id=users[2]) + usercodes = UserCode.objects.all() + + for user in users: + client.force_authenticate(user=user) + for usercode in usercodes: + usercode_detail_url = reverse( + "api:usercode-detail", kwargs={"pk": usercode.pk} + ) + response = client.get(usercode_detail_url) + if user == usercode.user: + assert response.status_code == status.HTTP_200_OK + else: + assert response.status_code == status.HTTP_404_NOT_FOUND + + response = client.patch(usercode_detail_url, {"code": "Hello world!"}) + if user == usercode.user: + assert response.status_code == status.HTTP_200_OK + else: + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/algo_sports/codes/views.py b/algo_sports/codes/views.py new file mode 100644 index 0000000..77587f8 --- /dev/null +++ b/algo_sports/codes/views.py @@ -0,0 +1,128 @@ +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.mixins import ListModelMixin +from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet, ModelViewSet + +from algo_sports.codes.filters import JudgementCodeFilter, UserCodeFilter +from algo_sports.utils.permissions import ( + IsAdminUser, + IsOwnerOrReadAndPostOnly, + IsSuperUser, +) + +from .models import JudgementCode, ProgrammingLanguage, UserCode +from .serializers import ( + JudgementCodeSerializer, + ProgrammingLanguageSerializer, + ProgrammingLanugaeTemplateSerializer, + UserCodeSerializer, +) + +# from django.utils.translation import gettext_lazy as _ + +User = get_user_model() + + +class ProgrammingLanguageViewSet(ListModelMixin, GenericViewSet): + """ + ProgrammingLanugae 리스트, Template rendering + """ + + serializer_class = ProgrammingLanguageSerializer + queryset = ProgrammingLanguage.objects.all() + lookup_field = "pk" + permission_classes = [IsAuthenticated] + + action_serializer_classes = { + "get_solution_template": ProgrammingLanugaeTemplateSerializer + } + + def get_queryset(self): + return self.queryset.filter(is_active=True) + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + + return super().get_serializer_class() + + @action(detail=True, methods=["POST"]) + def get_solution_template(self, request, *args, **kwargs): + language = self.get_object() + + parameters = request.data.get("parameters") + template_code = language.get_solution_template(parameters) + + return Response( + data={"template_code": template_code}, status=status.HTTP_200_OK + ) + + +class UserCodeViewSet(ModelViewSet): + """ + UserCode 리스트, 업데이트, 디테일 ViewSet + """ + + serializer_class = UserCodeSerializer + queryset = UserCode.objects.all() + lookup_field = "pk" + permission_classes = [ + IsAuthenticatedOrReadOnly, + IsOwnerOrReadAndPostOnly | IsSuperUser, + ] + filterset_class = UserCodeFilter + + action_serializer_classes = {} + + def get_queryset(self): + return self.queryset.filter(user_id=self.request.user) + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + + return super().get_serializer_class() + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(user_id=request.user) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + +class JudgementCodeViewSet(ModelViewSet): + """ + JudegementCode 리스트, 업데이트, 디테일 ViewSet + """ + + serializer_class = JudgementCodeSerializer + queryset = JudgementCode.objects.all() + lookup_field = "pk" + permission_classes = [IsAdminUser] + filterset_class = JudgementCodeFilter + + action_serializer_classes = {} + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + + return super().get_serializer_class() + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save(user_id=request.user) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) diff --git a/algo_sports/contrib/sites/migrations/0003_set_site_domain_and_name.py b/algo_sports/contrib/sites/migrations/0003_set_site_domain_and_name.py index 34def18..be5ebe6 100644 --- a/algo_sports/contrib/sites/migrations/0003_set_site_domain_and_name.py +++ b/algo_sports/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -13,7 +13,7 @@ def update_site_forward(apps, schema_editor): Site.objects.update_or_create( id=settings.SITE_ID, defaults={ - "domain": "algosports.com", + "domain": "api.asports.kr", "name": "algo_sports", }, ) diff --git a/algo_sports/contrib/sites/migrations/0004_auto_20201117_1332.py b/algo_sports/contrib/sites/migrations/0004_auto_20201117_1332.py new file mode 100644 index 0000000..73bc09d --- /dev/null +++ b/algo_sports/contrib/sites/migrations/0004_auto_20201117_1332.py @@ -0,0 +1,17 @@ +# Generated by Django 3.1.3 on 2020-11-17 13:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0003_set_site_domain_and_name'), + ] + + operations = [ + migrations.AlterModelOptions( + name='site', + options={'ordering': ['domain'], 'verbose_name': 'site', 'verbose_name_plural': 'sites'}, + ), + ] diff --git a/algo_sports/games/admin.py b/algo_sports/games/admin.py index 2a84d5f..d51890d 100644 --- a/algo_sports/games/admin.py +++ b/algo_sports/games/admin.py @@ -1,42 +1,73 @@ +# -*- coding: utf-8 -*- from django.contrib import admin -from .models import GameInfo, GameRoom +from .models import GameInfo, GameMatch, GameRoom, GameVersion @admin.register(GameInfo) class GameInfoAdmin(admin.ModelAdmin): - model = GameInfo - - list_display = [ + list_display = ( + "id", "title", - "version", + "description", "min_users", "max_users", + "extra_info", "created_at", "updated_at", - ] - search_fields = [ - "title", - ] - list_filter = [ - "title", - "min_users", - "max_users", - ] + ) + date_hierarchy = "created_at" + + +@admin.register(GameVersion) +class GameVersionAdmin(admin.ModelAdmin): + list_display = ( + "id", + "gameinfo_id", + "version", + "change_log", + "default_setting", + "is_active", + "created_at", + "updated_at", + ) + list_filter = ( + "gameinfo_id", + "is_active", + ) + date_hierarchy = "created_at" @admin.register(GameRoom) class GameRoomAdmin(admin.ModelAdmin): - model = GameRoom + list_display = ( + "id", + "gameversion_id", + "type", + "extra_setting", + "created_at", + "updated_at", + ) + list_filter = ( + "gameversion_id", + "type", + ) + date_hierarchy = "created_at" - list_display = [ + +@admin.register(GameMatch) +class GameMatchAdmin(admin.ModelAdmin): + list_display = ( "id", - "gameinfo", + "gameroom_id", + "history", + "score", "status", "created_at", - "finished_at", - ] - search_fields = [] - list_filter = [ + "updated_at", + ) + list_filter = ( + "gameroom_id", "status", - ] + ) + date_hierarchy = "created_at" diff --git a/algo_sports/games/choices.py b/algo_sports/games/choices.py new file mode 100644 index 0000000..580462f --- /dev/null +++ b/algo_sports/games/choices.py @@ -0,0 +1,23 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class GameType(models.TextChoices): + GENERAL = "GE", _("General") + PRACTICE = "PR", _("Practice") + RANKING = "RA", _("Ranking") + __empty__ = _("(Unknown)") + + +class GameStatus(models.TextChoices): + NOT_STARTED = "NS", _("Not started") + IN_PROGRESS = "IN", _("In progress") + FINISHED = "FN", _("Finished") + ERROR_OCCURED = "EO", _("Error occured") + __empty__ = _("(Unknown)") + + +class GameVersionType(models.TextChoices): + major = "major" + minor = "minor" + micro = "micro" diff --git a/algo_sports/games/filters.py b/algo_sports/games/filters.py new file mode 100644 index 0000000..b928ed2 --- /dev/null +++ b/algo_sports/games/filters.py @@ -0,0 +1,9 @@ +from django_filters import rest_framework as filters + +from .models import GameMatch + + +class GameMatchFilter(filters.FilterSet): + class Meta: + model = GameMatch + fields = ("gameroom_id",) diff --git a/algo_sports/games/migrations/0001_initial.py b/algo_sports/games/migrations/0001_initial.py index 1016191..4ca776b 100644 --- a/algo_sports/games/migrations/0001_initial.py +++ b/algo_sports/games/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.10 on 2020-11-13 01:18 +# Generated by Django 3.0.10 on 2020-11-14 16:12 import django.contrib.postgres.fields.jsonb from django.db import migrations, models @@ -9,62 +9,32 @@ class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + ] operations = [ migrations.CreateModel( - name="GameInfo", + name='GameInfo', fields=[ - ("gameinfo_id", models.AutoField(primary_key=True, serialize=False)), - ("title", models.CharField(max_length=50, verbose_name="Game title")), - ( - "version", - models.CharField(max_length=10, verbose_name="Game Version"), - ), - ("description", models.TextField(verbose_name="Game describtion")), - ( - "min_users", - models.PositiveSmallIntegerField( - verbose_name="Minimum User number" - ), - ), - ( - "max_users", - models.PositiveSmallIntegerField( - verbose_name="Maximum User number" - ), - ), - ( - "extra_info", - django.contrib.postgres.fields.jsonb.JSONField( - verbose_name="Additional information of Game" - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=50, verbose_name='Game title')), + ('version', models.CharField(max_length=10, verbose_name='Game Version')), + ('description', models.TextField(verbose_name='Game describtion')), + ('min_users', models.PositiveSmallIntegerField(verbose_name='Minimum User number')), + ('max_users', models.PositiveSmallIntegerField(verbose_name='Maximum User number')), + ('extra_info', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='Additional information of Game')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), migrations.CreateModel( - name="GameRoom", + name='GameRoom', fields=[ - ("gameroom_id", models.AutoField(primary_key=True, serialize=False)), - ( - "status", - models.PositiveSmallIntegerField( - default=False, verbose_name="Game status" - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("finished_at", models.DateTimeField(blank=True, null=True)), - ( - "gameinfo_id", - models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, - related_name="game_rooms", - to="games.GameInfo", - verbose_name="Game information", - ), - ), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.PositiveSmallIntegerField(default=False, verbose_name='Game status')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('gameinfo_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='game_rooms', to='games.GameInfo', verbose_name='Game information')), ], ), ] diff --git a/algo_sports/games/migrations/0002_auto_20201120_1723.py b/algo_sports/games/migrations/0002_auto_20201120_1723.py new file mode 100644 index 0000000..77ef096 --- /dev/null +++ b/algo_sports/games/migrations/0002_auto_20201120_1723.py @@ -0,0 +1,42 @@ +# Generated by Django 3.1.3 on 2020-11-20 17:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='gameroom', + name='finished_at', + ), + migrations.AddField( + model_name='gameroom', + name='setting', + field=models.JSONField(blank=True, default=dict, verbose_name='Additional setting for GameRoom'), + ), + migrations.AddField( + model_name='gameroom', + name='type', + field=models.CharField(choices=[(None, '(Unknown)'), ('GE', 'General'), ('PR', 'Practice'), ('RA', 'Ranking')], default='GE', max_length=2, verbose_name='Game Type'), + ), + migrations.AddField( + model_name='gameroom', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + migrations.AlterField( + model_name='gameinfo', + name='extra_info', + field=models.JSONField(verbose_name='Additional information of Game'), + ), + migrations.AlterField( + model_name='gameroom', + name='status', + field=models.CharField(choices=[(None, '(Unknown)'), ('NS', 'Not started'), ('FN', 'Finished'), ('EO', 'Error occured')], default='NS', max_length=2, verbose_name='Game status'), + ), + ] diff --git a/algo_sports/games/migrations/0003_auto_20201130_0130.py b/algo_sports/games/migrations/0003_auto_20201130_0130.py new file mode 100644 index 0000000..fdd9fbe --- /dev/null +++ b/algo_sports/games/migrations/0003_auto_20201130_0130.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.3 on 2020-11-30 01:30 + +import algo_sports.games.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0002_auto_20201120_1723'), + ] + + operations = [ + migrations.AlterModelOptions( + name='gameinfo', + options={'ordering': ['title']}, + ), + migrations.RemoveField( + model_name='gameinfo', + name='version', + ), + migrations.AlterField( + model_name='gameinfo', + name='title', + field=models.CharField(max_length=50, unique=True, verbose_name='Game title'), + ), + migrations.CreateModel( + name='GameVersion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.JSONField(default=algo_sports.games.models.make_version, verbose_name='Game Version')), + ('change_log', models.JSONField(default=dict, verbose_name='Version change log')), + ('is_active', models.BooleanField(default=True, verbose_name='Is this version active?')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('gameinfo_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='versions', to='games.gameinfo', verbose_name='Game information')), + ], + options={ + 'ordering': ['version__micro', 'version__minor', 'version__major'], + 'unique_together': {('gameinfo_id', 'version')}, + }, + ), + ] diff --git a/algo_sports/games/migrations/0004_auto_20201203_2123.py b/algo_sports/games/migrations/0004_auto_20201203_2123.py new file mode 100644 index 0000000..1e6261c --- /dev/null +++ b/algo_sports/games/migrations/0004_auto_20201203_2123.py @@ -0,0 +1,24 @@ +# Generated by Django 3.1.3 on 2020-12-03 21:23 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0003_auto_20201130_0130'), + ] + + operations = [ + migrations.RemoveField( + model_name='gameroom', + name='gameinfo_id', + ), + migrations.AddField( + model_name='gameroom', + name='gameversion_id', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.PROTECT, related_name='game_rooms', to='games.gameversion', verbose_name='Game Version'), + preserve_default=False, + ), + ] diff --git a/algo_sports/games/migrations/0005_auto_20201205_0158.py b/algo_sports/games/migrations/0005_auto_20201205_0158.py new file mode 100644 index 0000000..6c91639 --- /dev/null +++ b/algo_sports/games/migrations/0005_auto_20201205_0158.py @@ -0,0 +1,40 @@ +# Generated by Django 3.1.3 on 2020-12-05 01:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0004_auto_20201203_2123'), + ] + + operations = [ + migrations.RenameField( + model_name='gameroom', + old_name='setting', + new_name='extra_setting', + ), + migrations.RemoveField( + model_name='gameroom', + name='status', + ), + migrations.AddField( + model_name='gameversion', + name='default_setting', + field=models.JSONField(default=dict, verbose_name='Version default setting'), + ), + migrations.CreateModel( + name='GameMatch', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('history', models.JSONField(blank=True, default=dict, verbose_name='History in Game Match')), + ('score', models.IntegerField(blank=True, default=0, verbose_name='Match Score')), + ('status', models.CharField(choices=[(None, '(Unknown)'), ('NS', 'Not started'), ('FN', 'Finished'), ('EO', 'Error occured')], default='NS', max_length=2, verbose_name='Game Match Status')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('gameroom_id', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='game_matchs', to='games.gameroom', verbose_name='Game room')), + ], + ), + ] diff --git a/algo_sports/games/migrations/0006_auto_20201213_0345.py b/algo_sports/games/migrations/0006_auto_20201213_0345.py new file mode 100644 index 0000000..d822cbc --- /dev/null +++ b/algo_sports/games/migrations/0006_auto_20201213_0345.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.3 on 2020-12-13 03:45 + +import algo_sports.games.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0005_auto_20201205_0158'), + ] + + operations = [ + migrations.AlterField( + model_name='gameversion', + name='default_setting', + field=models.JSONField(default=algo_sports.games.models.get_default_setting, verbose_name='Version default setting'), + ), + ] diff --git a/algo_sports/games/migrations/0007_auto_20201213_1433.py b/algo_sports/games/migrations/0007_auto_20201213_1433.py new file mode 100644 index 0000000..ce586fe --- /dev/null +++ b/algo_sports/games/migrations/0007_auto_20201213_1433.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.3 on 2020-12-13 14:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('games', '0006_auto_20201213_0345'), + ] + + operations = [ + migrations.AlterField( + model_name='gamematch', + name='status', + field=models.CharField(choices=[(None, '(Unknown)'), ('NS', 'Not started'), ('IN', 'In progress'), ('FN', 'Finished'), ('EO', 'Error occured')], default='NS', max_length=2, verbose_name='Game Match Status'), + ), + ] diff --git a/algo_sports/games/models.py b/algo_sports/games/models.py index 60d6945..63b8480 100644 --- a/algo_sports/games/models.py +++ b/algo_sports/games/models.py @@ -1,56 +1,321 @@ -from django.contrib.postgres.fields import JSONField +from typing import Union + +import numpy +from django.contrib.auth import get_user_model from django.db import models +from django.db.models import Q +from django.http import Http404 +from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from .choices import GameStatus, GameType, GameVersionType + +User = get_user_model() + + +def make_version(major=0, minor=0, micro=1): + return { + GameVersionType.major: major, + GameVersionType.minor: minor, + GameVersionType.micro: micro, + } + class GameInfo(models.Model): """Information of Game""" - gameinfo_id = models.AutoField(primary_key=True) - - title = models.CharField(_("Game title"), max_length=50) - version = models.CharField(_("Game Version"), max_length=10) + title = models.CharField(_("Game title"), max_length=50, unique=True) description = models.TextField(_("Game describtion")) min_users = models.PositiveSmallIntegerField(_("Minimum User number")) max_users = models.PositiveSmallIntegerField(_("Maximum User number")) - extra_info = JSONField(_("Additional information of Game")) + extra_info = models.JSONField(_("Additional information of Game")) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + class Meta: + ordering = ["title"] + def __str__(self) -> str: - return f"{self.title} - v{self.version}" + return f"{self.title} ({self.total_versions})" + + def get_version(self, version: dict): + filtered = self.get_versions().filter(version=version) + if not filtered.exists(): + return Http404() + return filtered[0] + + def get_versions(self, ordering=False): + versions = self.versions.all() + if ordering: + versions = versions.order_by( + "-version__major", + "-version__minor", + "-version__micro", + ) + return versions @property - def id(self): - return self.gameinfo_id + def is_active(self): + return self.total_versions > 0 + @property + def change_log(self): + return self.get_versions(ordering=True).values_list("version", "change_log") + + @property + def latest_version(self): + return self.get_versions(ordering=True).first() + + @property + def total_versions(self): + return self.versions.all().count() + + def update_version( + self, + update_type: GameVersionType, + change_log: Union[str, list], + default_setting: dict = None, + target_version: dict = None, + ): + if not self.is_active: + # 게임 정보만 존재할 시 새로 버전을 생성 + GameVersion.objects.create(gameinfo_id=self, version=make_version(0, 0, 0)) + + version = None + if target_version: + version.get_versions(ordering=True).filter(version=target_version).first() + else: + version = self.latest_version + + if update_type == GameVersionType.major: + version = version.major_up() + elif update_type == GameVersionType.minor: + version = version.minor_up() + elif update_type == GameVersionType.micro: + version = version.micro_up() + else: + raise "Unexpected update type" + + if default_setting: + version.default_setting = default_setting + version.save() + return version.update_change_log(change_log) -class GameRoom(models.Model): - """Room for storing game status""" - gameroom_id = models.AutoField(primary_key=True) +def get_default_setting(): + return { + "includes": { + "Python (3.8.1)": [], + "C++ (GCC 9.2.0)": ["#include "], + "C (GCC 9.2.0)": [], + "JavaScript (Node.js 12.14.0)": [], + }, + "arguments": { + "Python (3.8.1)": ["argv[0]"], + "C++ (GCC 9.2.0)": ["argv[0]"], + "C (GCC 9.2.0)": ["argv[0]"], + "JavaScript (Node.js 12.14.0)": ["argv[0]"], + }, + "parameters": { + "Python (3.8.1)": ["greeting: str"], + "C++ (GCC 9.2.0)": ["std::string greeting"], + "C (GCC 9.2.0)": ["const char* greeting"], + "JavaScript (Node.js 12.14.0)": ["greeting"], + }, + } + +class GameVersion(models.Model): gameinfo_id = models.ForeignKey( GameInfo, - verbose_name="Game information", + verbose_name=_("Game information"), on_delete=models.PROTECT, - related_name="game_rooms", + related_name="versions", ) - status = models.PositiveSmallIntegerField(_("Game status"), default=False) + + version = models.JSONField(_("Game Version"), default=make_version) + change_log = models.JSONField(_("Version change log"), default=dict) + default_setting = models.JSONField( + _("Version default setting"), default=get_default_setting + ) + is_active = models.BooleanField(_("Is this version active?"), default=True) created_at = models.DateTimeField(auto_now_add=True) - finished_at = models.DateTimeField(blank=True, null=True) + updated_at = models.DateTimeField(auto_now=True) def __str__(self) -> str: - return f"{self.id}. {self.gameinfo} ({self.status})" + return f"{self.gameinfo.title} ({self.version_str})" + + class Meta: + unique_together = ["gameinfo_id", "version"] + ordering = [ + "version__micro", + "version__minor", + "version__major", + ] + + @cached_property + def gameinfo(self): + return self.gameinfo_id + + @property + def support_languages(self) -> set([str]): + languages = set() + for language_setting in self.default_setting.values(): + languages |= language_setting.keys() + return languages @property - def id(self): - return self.gameroom_id + def version_str(self): + version = f"v{self.version[GameVersionType.major]}." + version += f"{self.version[GameVersionType.minor]}." + version += f"{self.version[GameVersionType.micro]}" + return version + + def major_up(self): + self.pk = None + self.version[GameVersionType.major] += 1 + self.version[GameVersionType.minor] = 0 + self.version[GameVersionType.micro] = 0 + self.change_log = {} + self.save() + return self + + def minor_up(self): + self.version[GameVersionType.minor] += 1 + self.version[GameVersionType.micro] = 0 + self.save() + return self + + def micro_up(self): + self.version[GameVersionType.micro] += 1 + self.save() + return self + + def update_change_log(self, changes: Union[str, list]): + self.change_log.setdefault(self.version_str, []) + if isinstance(changes, str): + self.change_log.get(self.version_str).append(changes) + else: + self.change_log.get(self.version_str).extend(changes) + self.save() + return self + + +class GameRoom(models.Model): + """Room for storing game status""" + + gameversion_id = models.ForeignKey( + GameVersion, + verbose_name=_("Game Version"), + on_delete=models.PROTECT, + related_name="game_rooms", + ) + + type = models.CharField( + _("Game Type"), + max_length=2, + choices=GameType.choices, + default=GameType.GENERAL, + ) + + extra_setting = models.JSONField( + _("Additional setting for GameRoom"), + blank=True, + default=dict, + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self) -> str: + return f"Room {self.id}. {self.gameversion} ({self.type})" @property + def is_active(self): + min_user = self.gameinfo.min_users + return self.active_participants.count() > min_user + + @cached_property def gameinfo(self): - return self.gameinfo_id + return self.gameversion.gameinfo + + @cached_property + def gameversion(self): + return self.gameversion_id + + @property + def participants(self): + return self.usercodes.all() + + @property + def active_participants(self): + return self.participants.select_related("programming_language").filter( + programming_language__is_active=True, is_active=True + ) + + def sample_active_participants(self, exclude_user) -> list([int]): + if not self.is_active: + return [] + + # 자신을 제외한 유저코드 추출 + queryset = self.active_participants.filter( + ~Q(user_id=exclude_user) + ).values_list("id", flat=True) + actives = queryset.count() + + # 매치를 시작할 유저를 제외한 수 + max_users = self.gameinfo.max_users - 1 + min_users = self.gameinfo.min_users - 1 + + sample_size = 0 + if actives > 0 and actives < max_users: + sample_size = min_users + elif actives >= max_users: + sample_size = max_users + + sampled = numpy.array([]) + if sample_size > 0: + sampled = numpy.random.choice(queryset, size=sample_size, replace=False) + + # 균일 추출, 동일값 없음. + return sampled.tolist() + + +class GameMatch(models.Model): + """Match in Game room""" + + gameroom_id = models.ForeignKey( + GameRoom, + verbose_name=_("Game room"), + on_delete=models.PROTECT, + related_name="game_matchs", + ) + + history = models.JSONField( + _("History in Game Match"), + blank=True, + default=dict, + ) + + score = models.IntegerField(_("Match Score"), blank=True, default=0) + + status = models.CharField( + _("Game Match Status"), + max_length=2, + choices=GameStatus.choices, + default=GameStatus.NOT_STARTED, + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self) -> str: + return f"{self.id}. ({self.status})" + + def set_status(self, status: GameStatus): + self.status = status + self.save() diff --git a/algo_sports/games/serializers.py b/algo_sports/games/serializers.py new file mode 100644 index 0000000..b1113ef --- /dev/null +++ b/algo_sports/games/serializers.py @@ -0,0 +1,107 @@ +from rest_framework import serializers + +from algo_sports.codes.models import UserCode + +from .models import GameInfo, GameMatch, GameRoom, GameVersion, GameVersionType + + +class GameVersionSerializer(serializers.ModelSerializer): + support_languages = serializers.ListField( + child=serializers.CharField(), read_only=True + ) + + class Meta: + model = GameVersion + exclude = ["change_log"] + + +class GameInfoSerializer(serializers.ModelSerializer): + latest_version = serializers.SerializerMethodField(method_name="_version") + default_setting = serializers.JSONField(required=False) + + class Meta: + model = GameInfo + fields = "__all__" + + def _version(self, instance): + version = None + if instance.latest_version: + version = instance.latest_version.version_str + return version or "None" + + +class GameInfoDetailSerializer(serializers.ModelSerializer): + change_log = serializers.JSONField() + default_setting = serializers.JSONField(write_only=True) + version = serializers.SerializerMethodField(method_name="_version") + + class Meta: + model = GameInfo + fields = "__all__" + + def _version(self, instance): + version = instance.latest_version + print(version) + return GameVersionSerializer(instance=version).data + + +class GameInfoUpdateSerializer(serializers.ModelSerializer): + update_type = serializers.ChoiceField( + choices=GameVersionType.choices, required=False + ) + change_log = serializers.ListField(required=False) + + class Meta: + model = GameInfo + fields = "__all__" + read_only_fields = ["title", "description"] + + def validate(self, attrs): + if attrs["max_users"] > attrs["min_users"]: + raise serializers.ValidationError("max_users가 min_users 보다 작습니다.") + return attrs + + +class GameRoomCreateSerializer(serializers.ModelSerializer): + version = serializers.JSONField(required=False) + + class Meta: + model = GameRoom + exclude = ["gameversion_id"] + + +class GameRoomSerializer(serializers.ModelSerializer): + total_active_participants = serializers.SerializerMethodField( + method_name="_total_active_participants" + ) + total_participants = serializers.SerializerMethodField( + method_name="_total_participants" + ) + template_code = serializers.CharField(read_only=True) + gameversion = GameVersionSerializer() + gameinfo = GameInfoSerializer() + + class Meta: + model = GameRoom + fields = "__all__" + + def _total_active_participants(self, instance): + return instance.active_participants.count() + + def _total_participants(self, instance): + return instance.participants.count() + + +class GameMatchSerializer(serializers.ModelSerializer): + mycode_id = serializers.PrimaryKeyRelatedField( + queryset=UserCode.objects.all(), required=False + ) + + class Meta: + model = GameMatch + fields = "__all__" + extra_kwargs = { + "history": {"read_only": True}, + "score": {"read_only": True}, + "status": {"read_only": True}, + } diff --git a/algo_sports/games/tasks.py b/algo_sports/games/tasks.py new file mode 100644 index 0000000..fbc5d27 --- /dev/null +++ b/algo_sports/games/tasks.py @@ -0,0 +1,59 @@ +import subprocess +from pathlib import Path + +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 + +from algo_sports.codes.models import UserCode +from algo_sports.games.models import GameMatch, GameRoom +from config import celery_app + +from .choices import GameStatus + +User = get_user_model() + + +@celery_app.task() +def run_match(match_data): + # 게임 진행중으로 변경 + match = get_object_or_404(GameMatch, pk=match_data.get("gamematch_id")) + match.set_status(GameStatus.IN_PROGRESS) + + room = get_object_or_404(GameRoom, pk=match_data.get("gameroom_id")) + competitors = UserCode.objects.filter(id__in=match_data.get("competitor_ids")) + default_setting = room.gameversion.default_setting + + for competitor in competitors: + language = competitor.programming_language + + # 실행 변수들 + run_cmd = language.run_cmd + compile_cmd = language.compile_cmd + + # main 렌더링 + includes = default_setting["includes"][language.name] + arguments = default_setting["arguments"][language.name] + solution = competitor.code + code_string = str(language.get_main_template(includes, arguments, solution)) + + # 코드 파일 생성 + parent = Path(f"match_datas/room_{room.id}/match_{match.id}/") + parent.mkdir(parents=True, exist_ok=True) + file_name = f"code_{competitor.id}.{language.extension}" + file_path = parent / file_name + with file_path.open("w", encoding="utf-8") as f: + gen_code = ["echo", f"{code_string}"] + subprocess.run(gen_code, stdout=f, check=True) + + # 커맨드 생성 + run_code = ["./run.sh", "-r", f"{run_cmd}", "-s", f"{file_path}"] + if compile_cmd: + run_code.extend(["-c", f"{compile_cmd}"]) + + # output + try: + output = subprocess.run(run_code, check=True, capture_output=True) + print(output) + except UnboundLocalError: + print(f"Error! competitor id : {competitor.id}") + match.set_status(GameStatus.FINISHED) diff --git a/algo_sports/games/tests/factories.py b/algo_sports/games/tests/factories.py index d43c9b6..d731718 100644 --- a/algo_sports/games/tests/factories.py +++ b/algo_sports/games/tests/factories.py @@ -1,27 +1,55 @@ +from factory import Sequence from factory.declarations import SubFactory from factory.django import DjangoModelFactory from factory.faker import Faker +from factory.fuzzy import FuzzyChoice, FuzzyInteger -from algo_sports.games.models import GameInfo, GameRoom +from algo_sports.games.choices import GameType +from algo_sports.games.models import GameInfo, GameMatch, GameRoom, GameVersion + +fake_word = Faker("word") class GameInfoFactory(DjangoModelFactory): - title = Faker("title") - version = Faker("version") - description = Faker("description") + title = Sequence(lambda x: f"{fake_word.generate()} {x}") + description = Faker("sentence") - min_users = Faker("min_users") - max_users = Faker("max_users") + min_users = FuzzyInteger(2, 4) + max_users = FuzzyInteger(5, 10) - extra_info = Faker("extra_info") + extra_info = { + "game_setting1": { + "customize": True, + "varient": 1000, + }, + } class Meta: model = GameInfo -class GameRoomFactory(DjangoModelFactory): +class GameVersionFactory(DjangoModelFactory): gameinfo_id = SubFactory(GameInfoFactory) - status = Faker("status") + + class Meta: + model = GameVersion + + +class GameRoomFactory(DjangoModelFactory): + gameversion_id = SubFactory(GameVersionFactory) + type = FuzzyChoice(GameType.choices) + extra_setting = { + "game_setting1": { + "varient": 300, + }, + } class Meta: model = GameRoom + + +class GameMatchFactory(DjangoModelFactory): + gameroom_id = SubFactory(GameRoomFactory) + + class Meta: + model = GameMatch diff --git a/algo_sports/games/views.py b/algo_sports/games/views.py new file mode 100644 index 0000000..f5387ca --- /dev/null +++ b/algo_sports/games/views.py @@ -0,0 +1,258 @@ +from django.contrib.auth import get_user_model +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.mixins import ( + CreateModelMixin, + ListModelMixin, + RetrieveModelMixin, + UpdateModelMixin, +) +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from algo_sports.games.filters import GameMatchFilter +from algo_sports.utils.permissions import IsAdminOrReadOnly + +from .models import GameInfo, GameMatch, GameRoom, GameVersion, GameVersionType +from .serializers import ( + GameInfoDetailSerializer, + GameInfoSerializer, + GameInfoUpdateSerializer, + GameMatchSerializer, + GameRoomCreateSerializer, + GameRoomSerializer, +) +from .tasks import run_match + +# from django.utils.translation import gettext_lazy as _ + +User = get_user_model() + + +class GameInfoViewSet( + ListModelMixin, + CreateModelMixin, + UpdateModelMixin, + RetrieveModelMixin, + GenericViewSet, +): + serializer_class = GameInfoSerializer + queryset = GameInfo.objects.all() + lookup_field = "pk" + permission_classes = [IsAdminOrReadOnly] + + action_serializer_classes = { + "retrieve": GameInfoDetailSerializer, + "update": GameInfoUpdateSerializer, + "partial_update": GameInfoUpdateSerializer, + "create_gameroom": GameRoomCreateSerializer, + } + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + return super().get_serializer_class() + + def create(self, request, *args, **kwargs): + """ + GameInfo 생성할 때 Version을 하나 생성한다. + """ + version_args = {} + + default_setting = request.data.get("default_setting") + if default_setting: + request.data.pop("default_setting") + version_args["default_setting"] = default_setting + + response = super().create(request, *args, **kwargs) + gameinfo = GameInfo.objects.get(id=response.data.get("id")) + version_args["gameinfo_id"] = gameinfo + + GameVersion.objects.create(**version_args) + + serializer = self.get_serializer(instance=gameinfo) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, status=status.HTTP_201_CREATED, headers=headers + ) + + def update(self, request, *args, **kwargs): + """ + GameInfo의 Version을 한 단계 올린다. + """ + gameinfo = self.get_object() + + update_type = request.data.get("update_type") + if update_type: + request.data.pop("update_type") + update_type = GameVersionType.micro + + default_setting = request.data.get("default_setting") + if default_setting: + request.data.pop("default_setting") + + change_log = request.data.get("change_log") + if change_log: + request.data.pop("change_log") + + if change_log: + if default_setting: + gameinfo.update_version( + update_type=update_type, + change_log=change_log, + default_setting=default_setting, + ) + else: + gameinfo.update_version( + update_type=update_type, + change_log=change_log, + ) + return super().update(request, *args, **kwargs) + + return Response( + data={"detail": "Change log is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @action(detail=True, methods=["GET"]) + def get_active_versions(self, request): + queryset = self.queryset.get_versions(ordering=True) + queryset = queryset.filter(is_active=True) + return Response(status=status.HTTP_200_OK) + + @action(detail=True, methods=["POST"]) + def create_gameroom(self, request, *args, **kwargs): + gameinfo = self.get_object() + + version = request.data.get("version") + + gameversion_id = None + if version: + gameversion_id = gameinfo.get_version(version=version) + request.data.pop("version") + else: + gameversion_id = gameinfo.latest_version + + serializers = self.get_serializer(data=request.data) + serializers.is_valid() + serializers.save(gameversion_id=gameversion_id) + + return Response(data=serializers.data, status=status.HTTP_201_CREATED) + + +class GameRoomViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + serializer_class = GameRoomSerializer + queryset = GameRoom.objects.all() + lookup_field = "pk" + + action_serializer_classes = {} + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + return super().get_serializer_class() + + +def make_run_match_parameter( + gameroom_id: int, + gamematch_id: int, + competitor_ids: list([int]), +): + return { + "gameroom_id": gameroom_id, + "gamematch_id": gamematch_id, + "competitor_ids": competitor_ids, + } + + +class GameMatchViewSet( + ListModelMixin, RetrieveModelMixin, CreateModelMixin, GenericViewSet +): + serializer_class = GameMatchSerializer + queryset = GameMatch.objects.all() + + action_serializer_classes = {} + + filterset_class = GameMatchFilter + + def get_queryset(self): + gamematche_ids = self.request.user.usercodes.values_list( + "gamematches", flat=True + ) + return GameMatch.objects.filter(id__in=gamematche_ids) + + def get_serializer_class(self): + serializer = self.action_serializer_classes.get(self.action) + if serializer: + return serializer + return super().get_serializer_class() + + def create(self, request, *args, **kwargs): + """ Create match """ + # match에 직접적으로 저장되지 않는 데이터 추출 + mycode_id = request.data.get("mycode_id") + if mycode_id: + request.data.pop("mycode_id") + + # 저장은 안하고 유효한 입력인지 확인 진행 + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # gameroom object + gameroom = serializer.validated_data.get("gameroom_id") + + # mycode object + mycode_obj = gameroom.participants.filter(pk=mycode_id) + if not mycode_obj.exists(): + # 방에 참가한 코드인지 확인 + return Response( + data={"msg": _("현재 방에 참가하지 않은 코드입니다.")}, + status=status.HTTP_400_BAD_REQUEST, + ) + elif not mycode_obj[0].user.id == request.user.id: + # 현재 로그인한 유저의 코드인지 확인 + return Response( + data={"msg": _("소유하지 않은 코드입니다.")}, + status=status.HTTP_403_FORBIDDEN, + ) + + # 경쟁 코드들 샘플링 + competitor_ids = gameroom.sample_active_participants( + exclude_user=self.request.user + ) + + # 참가자가 충분한지 확인 + if len(competitor_ids) == 0: + return Response( + data={"msg": _("참가자들이 부족합니다.")}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # 매치를 만드는 사용자의 코드를 0번 인덱스에 추가 + competitor_ids.insert(0, mycode_id) + + # 유효한 입력이므로 Match 생성 + serializer.save() + + # 참가할 매치 + gamematch = get_object_or_404(GameMatch, pk=serializer.data.get("id")) + gamematch.usercodes.add(*competitor_ids) + + # celery task 실행 + run_match.delay( + make_run_match_parameter( + gameroom.id, + gamematch.id, + competitor_ids, + ) + ) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + headers=headers, + ) diff --git a/algo_sports/templates/base.html b/algo_sports/templates/base.html index d55b28a..9725cf6 100644 --- a/algo_sports/templates/base.html +++ b/algo_sports/templates/base.html @@ -39,36 +39,8 @@ - algo_sports - - diff --git a/algo_sports/tests/test_permission.py b/algo_sports/tests/test_permission.py new file mode 100644 index 0000000..0d7c41b --- /dev/null +++ b/algo_sports/tests/test_permission.py @@ -0,0 +1,52 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from algo_sports.blogs.tests.factories import PostFactory +from algo_sports.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +class TestPermission: + def test_is_owner_or_readonly(self): + client = APIClient() + + users = [ + UserFactory(), + UserFactory(), + ] + + owner = users[0] + another = users[1] + + post = PostFactory(user_id=owner) + url = reverse("api:post-detail", kwargs={"pk": post.pk}) + views = [ + ( + client.put, + {"title": "Hello", "content": "put method~"}, + status.HTTP_200_OK, + ), + ( + client.patch, + {"title": "Hello2"}, + status.HTTP_200_OK, + ), + ( + client.delete, + None, + status.HTTP_204_NO_CONTENT, + ), + ] + + for method, param, sts in views: + client.force_authenticate(user=another) + response = method(url, param) + assert response.status_code == status.HTTP_403_FORBIDDEN + + for method, param, sts in views: + client.force_authenticate(user=owner) + response = method(url, param) + assert response.status_code == sts diff --git a/algo_sports/users/migrations/0001_initial.py b/algo_sports/users/migrations/0001_initial.py index eafdb66..951c722 100644 --- a/algo_sports/users/migrations/0001_initial.py +++ b/algo_sports/users/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.0.10 on 2020-11-13 06:14 +# Generated by Django 3.0.10 on 2020-11-14 16:12 import django.contrib.auth.models import django.contrib.auth.validators @@ -18,6 +18,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), @@ -28,7 +29,6 @@ class Migration(migrations.Migration): ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('user_id', models.AutoField(primary_key=True, serialize=False)), ('name', models.CharField(blank=True, max_length=255, verbose_name='Name of User')), ('language', models.CharField(blank=True, max_length=30, verbose_name='Language of User')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), diff --git a/algo_sports/users/migrations/0002_auto_20201117_1332.py b/algo_sports/users/migrations/0002_auto_20201117_1332.py new file mode 100644 index 0000000..8fcc745 --- /dev/null +++ b/algo_sports/users/migrations/0002_auto_20201117_1332.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.3 on 2020-11-17 13:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='first_name', + field=models.CharField(blank=True, max_length=150, verbose_name='first name'), + ), + ] diff --git a/algo_sports/users/models.py b/algo_sports/users/models.py index db81ce2..6f4e020 100644 --- a/algo_sports/users/models.py +++ b/algo_sports/users/models.py @@ -1,15 +1,25 @@ from django.contrib.auth.models import AbstractUser -from django.db.models import AutoField, CharField +from django.db.models import CharField from django.utils.translation import gettext_lazy as _ +from algo_sports.utils.choices import PermissionChoices + class User(AbstractUser): """Default user for algo_sports.""" - user_id = AutoField(primary_key=True) name = CharField(_("Name of User"), blank=True, max_length=255) language = CharField(_("Language of User"), blank=True, max_length=30) @property - def id(self): - return self.user_id + def is_admin(self): + return self.is_superuser or self.is_staff + + @property + def level(self): + user_level = 1 + if self.is_superuser: + user_level = PermissionChoices.ADMIN.value + elif self.is_staff: + user_level = PermissionChoices.STAFF.value + return user_level diff --git a/algo_sports/users/serializers.py b/algo_sports/users/serializers.py index 193b9f5..64f870d 100644 --- a/algo_sports/users/serializers.py +++ b/algo_sports/users/serializers.py @@ -7,8 +7,10 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["username", "email", "name", "language", "url"] + fields = ["username", "email", "name", "language"] - extra_kwargs = { - "url": {"view_name": "api:user-detail", "lookup_field": "username"} - } + +class UsernameSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["username"] diff --git a/algo_sports/users/tests/factories.py b/algo_sports/users/tests/factories.py index f1c5eca..ae0a33d 100644 --- a/algo_sports/users/tests/factories.py +++ b/algo_sports/users/tests/factories.py @@ -10,7 +10,7 @@ class UserFactory(DjangoModelFactory): username = Faker("user_name") email = Faker("email") name = Faker("name") - language = Faker("language") + language = "en" @post_generation def password(self, create: bool, extracted: Sequence[Any], **kwargs): diff --git a/algo_sports/users/tests/test_drf_views.py b/algo_sports/users/tests/test_drf_views.py deleted file mode 100644 index 3367d79..0000000 --- a/algo_sports/users/tests/test_drf_views.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest -from django.test import RequestFactory - -from algo_sports.users.models import User -from algo_sports.users.views import UserViewSet - -pytestmark = pytest.mark.django_db - - -class TestUserViewSet: - def test_get_queryset(self, user: User, rf: RequestFactory): - view = UserViewSet() - request = rf.get("/fake-url/") - request.user = user - - view.request = request - - assert user in view.get_queryset() - - def test_me(self, user: User, rf: RequestFactory): - view = UserViewSet() - request = rf.get("/fake-url/") - request.user = user - - view.request = request - - response = view.me(request) - - assert response.data == { - "username": user.username, - "email": user.email, - "name": user.name, - "url": f"http://testserver/api/users/{user.username}/", - } diff --git a/algo_sports/users/tests/test_drf_urls.py b/algo_sports/users/tests/test_user_urls.py similarity index 100% rename from algo_sports/users/tests/test_drf_urls.py rename to algo_sports/users/tests/test_user_urls.py diff --git a/algo_sports/users/tests/test_user_viewset.py b/algo_sports/users/tests/test_user_viewset.py new file mode 100644 index 0000000..1a73010 --- /dev/null +++ b/algo_sports/users/tests/test_user_viewset.py @@ -0,0 +1,37 @@ +import pytest +from django.urls.base import reverse +from rest_framework.test import APIClient, APIRequestFactory + +from algo_sports.users.serializers import UserSerializer +from algo_sports.users.tests.factories import UserFactory +from algo_sports.users.views import UserViewSet + +pytestmark = pytest.mark.django_db + + +class TestUserViewSet: + def test_get_queryset(self): + view = UserViewSet() + + user = UserFactory() + + rf = APIRequestFactory() + request = rf.get("/fake-url/") + request.user = user + + view.request = request + + assert user in view.get_queryset() + + def test_me(self): + client = APIClient() + + users = UserFactory.create_batch(10) + + user_me_url = reverse("api:user-me") + for user in users: + client.force_authenticate(user=user) + response = client.get(user_me_url) + + serializer = UserSerializer(instance=user) + assert serializer.data == response.json() diff --git a/algo_sports/users/urls.py b/algo_sports/users/urls.py new file mode 100644 index 0000000..a94b619 --- /dev/null +++ b/algo_sports/users/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from algo_sports.users.views import ( + user_detail_view, + user_redirect_view, + user_update_view, +) + +app_name = "users" +urlpatterns = [ + path("~redirect/", view=user_redirect_view, name="redirect"), + path("~update/", view=user_update_view, name="update"), + path("/", view=user_detail_view, name="detail"), +] diff --git a/algo_sports/users/views.py b/algo_sports/users/views.py index c8f2bd9..969f0f2 100644 --- a/algo_sports/users/views.py +++ b/algo_sports/users/views.py @@ -1,4 +1,9 @@ +from django.contrib import messages from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView, RedirectView, UpdateView from rest_framework import status from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin @@ -17,10 +22,52 @@ class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericV queryset = User.objects.all() lookup_field = "username" - def get_queryset(self, *args, **kwargs): + def get_queryset(self): return self.queryset.filter(id=self.request.user.id) @action(detail=False, methods=["GET"]) - def me(self, request): - serializer = UserSerializer(request.user, context={"request": request}) + def me(self, request, *args, **kwargs): + serializer = self.get_serializer(request.user) return Response(status=status.HTTP_200_OK, data=serializer.data) + + +class UserDetailView(LoginRequiredMixin, DetailView): + + model = User + slug_field = "username" + slug_url_kwarg = "username" + + +user_detail_view = UserDetailView.as_view() + + +class UserUpdateView(LoginRequiredMixin, UpdateView): + + model = User + fields = ["name"] + + def get_success_url(self): + return reverse("users:detail", kwargs={"username": self.request.user.username}) + + def get_object(self): + return User.objects.get(username=self.request.user.username) + + def form_valid(self, form): + messages.add_message( + self.request, messages.INFO, _("Infos successfully updated") + ) + return super().form_valid(form) + + +user_update_view = UserUpdateView.as_view() + + +class UserRedirectView(LoginRequiredMixin, RedirectView): + + permanent = False + + def get_redirect_url(self): + return reverse("users:detail", kwargs={"username": self.request.user.username}) + + +user_redirect_view = UserRedirectView.as_view() diff --git a/algo_sports/utils/choices.py b/algo_sports/utils/choices.py new file mode 100644 index 0000000..b5b7813 --- /dev/null +++ b/algo_sports/utils/choices.py @@ -0,0 +1,7 @@ +from django.db.models import IntegerChoices + + +class PermissionChoices(IntegerChoices): + ADMIN = 3, "Admin only" + STAFF = 2, "Staff only" + ALL = 1, "Allow any" diff --git a/algo_sports/utils/paginations.py b/algo_sports/utils/paginations.py new file mode 100644 index 0000000..c3a96d8 --- /dev/null +++ b/algo_sports/utils/paginations.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class SizeQueryPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "size" + max_page_size = 100 diff --git a/algo_sports/utils/permissions.py b/algo_sports/utils/permissions.py new file mode 100644 index 0000000..b0f3373 --- /dev/null +++ b/algo_sports/utils/permissions.py @@ -0,0 +1,55 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsSuperUser(BasePermission): + """ + superuser는 모든 권한을 가진다. + """ + + def has_permission(self, request, view): + return request.user.is_superuser + + def has_object_permission(self, request, view, obj): + return request.user.is_superuser + + +class IsAdminUser(BasePermission): + """ + Admin users만 접근 가능 + """ + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + print("access : ", request.user.is_admin) + return bool(request.user and request.user.is_admin) + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + print("object : ", request.user.is_admin) + return bool(request.user and request.user.is_admin) + + +class IsAdminOrReadOnly(BasePermission): + """ + Admin users만 Post 가능하도록 + """ + + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + + return bool(request.user and request.user.is_admin) + + +class IsOwnerOrReadAndPostOnly(BasePermission): + """ + 해당 객체를 생성한 user만 수정 권한을 부여 + """ + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS or request.method in "POST": + return True + + return obj.user == request.user diff --git a/algo_sports/utils/test/__init__.py b/algo_sports/utils/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/algo_sports/utils/test/compare_url.py b/algo_sports/utils/test/compare_url.py new file mode 100644 index 0000000..1d52129 --- /dev/null +++ b/algo_sports/utils/test/compare_url.py @@ -0,0 +1,5 @@ +from urllib.parse import quote + + +def compare_url(quoted_url, raw_url): + return quoted_url == quote(raw_url) diff --git a/aws/assume-role.json b/aws/assume-role.json new file mode 100644 index 0000000..a466d07 --- /dev/null +++ b/aws/assume-role.json @@ -0,0 +1,12 @@ +{ + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ], + "Version": "2012-10-17" +} diff --git a/aws/ecs-params.yml b/aws/ecs-params.yml new file mode 100644 index 0000000..039f888 --- /dev/null +++ b/aws/ecs-params.yml @@ -0,0 +1,14 @@ +version: 1 +task_definition: + task_execution_role: ecs-instance + task_size: + mem_limit: 512 + cpu_limit: 256 +run_params: + network_configuration: + awsvpc_configuration: + subnets: + - "subnet-0a00bab6d3dec1b98" + - "subnet-024304174c7677aa4" + security_groups: + - "sg-02883320b4d5e11a9" diff --git a/aws/user_data.sh b/aws/user_data.sh new file mode 100644 index 0000000..f32fdde --- /dev/null +++ b/aws/user_data.sh @@ -0,0 +1,2 @@ +#/bin/bash date -d +echo ECS_CLUSTER=jts-cluster >> /etc/ecs/ecs.config diff --git a/compose/local/django/Dockerfile b/compose/local/django/Dockerfile index 1cd4c76..abff33a 100644 --- a/compose/local/django/Dockerfile +++ b/compose/local/django/Dockerfile @@ -1,3 +1,5 @@ +FROM judge0/compilers AS base + FROM python:3.8-slim-buster ENV PYTHONUNBUFFERED 1 diff --git a/compose/production/nginx/Dockerfile b/compose/production/nginx/Dockerfile new file mode 100644 index 0000000..1ff961c --- /dev/null +++ b/compose/production/nginx/Dockerfile @@ -0,0 +1,12 @@ +FROM nginx:1.19.5-alpine + +RUN rm /etc/nginx/conf.d/default.conf + +COPY nginx.conf /etc/nginx/nginx.conf +COPY server.conf /etc/nginx/conf.d + +RUN mkdir /data \ + && mkdir /data/nginx \ + && mkdir /data/nginx/temp \ + && mkdir /data/nginx/cache +EXPOSE 80 diff --git a/compose/production/nginx/nginx.conf b/compose/production/nginx/nginx.conf new file mode 100644 index 0000000..8d56e3a --- /dev/null +++ b/compose/production/nginx/nginx.conf @@ -0,0 +1,44 @@ +user nginx; +worker_processes 2; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # gzip 설정 + gzip on; + gzip_proxied any; + gzip_types text/plain application/json; + gzip_min_length 1000; + + # 용량이 큰 파일을 업로드 가능하도록 설정 + client_max_body_size 10M; + + server_tokens off; + fastcgi_hide_header X-Powered-By; + fastcgi_hide_header X-Pingback; + fastcgi_hide_header Link; + proxy_hide_header X-Powered-By; + proxy_hide_header X-Pingback; + proxy_hide_header X-Link; + + # 캐시 저장소 + proxy_cache_path /data/nginx/cache keys_zone=one:10m; + + # 임시 파일 저장소 + proxy_temp_path /data/nginx/temp; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + include /etc/nginx/conf.d/*.conf; +} diff --git a/compose/production/nginx/server.conf b/compose/production/nginx/server.conf new file mode 100644 index 0000000..38857c8 --- /dev/null +++ b/compose/production/nginx/server.conf @@ -0,0 +1,61 @@ +upstream algoweb { + ip_hash; + server django:5000; +} + +server { + listen 80; + server_name api.asports.kr; + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # .py 나 .log 파일들에 접근을 차단 + location ~ /\. { + deny all; + } + location ~* ^.+\.(py|log)$ { + deny all; + } + + set $bucket "algo-sports-bucket.s3.amazonaws.com"; + + # static 설정 + location /static/ { + try_files $uri @s3; + # http://$bucket/static/; + } + + # media 설정 + location /media/ { + try_files $uri @s3; + # proxy_pass http://$bucket/media/; + } + + location @s3 { + set $url_full '$1'; + + proxy_http_version 1.1; + proxy_set_header Host $bucket; + proxy_set_header Authorization ''; + proxy_hide_header x-amz-id-2; + proxy_hide_header x-amz-request-id; + proxy_hide_header x-amz-meta-server-side-encryption; + proxy_hide_header x-amz-server-side-encryption; + proxy_hide_header Set-Cookie; + proxy_ignore_headers Set-Cookie; + proxy_intercept_errors on; + + resolver 8.8.4.4 8.8.8.8 valid=300s; + resolver_timeout 10s; + proxy_pass http://$bucket$url_full; + } + + location / { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + proxy_pass http://algoweb; + } +} diff --git a/compose/production/traefik/traefik.yml b/compose/production/traefik/traefik.yml index 42026eb..8167ea1 100644 --- a/compose/production/traefik/traefik.yml +++ b/compose/production/traefik/traefik.yml @@ -21,13 +21,13 @@ certificatesResolvers: storage: /etc/traefik/acme/acme.json # https://docs.traefik.io/master/https/acme/#httpchallenge httpChallenge: - entryPoint: web + entryPoint: web-secure http: routers: web-router: - rule: "Host(`algosports.com`) || Host(`www.algosports.com`)" - + rule: "Host(`asports.kr`) || Host(`www.asports.kr`)" + entryPoints: - web middlewares: @@ -36,8 +36,8 @@ http: service: django web-secure-router: - rule: "Host(`algosports.com`) || Host(`www.algosports.com`)" - + rule: "Host(`asports.kr`) || Host(`www.asports.kr`)" + entryPoints: - web-secure middlewares: @@ -48,7 +48,7 @@ http: certResolver: letsencrypt flower-secure-router: - rule: "Host(`algosports.com`)" + rule: "Host(`asports.kr`)" entryPoints: - flower service: flower diff --git a/config/api_router.py b/config/api_router.py index fe7f831..ab0ea62 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -1,6 +1,13 @@ from django.conf import settings from rest_framework.routers import DefaultRouter, SimpleRouter +from algo_sports.blogs.views import BlogViewSet, CommentViewSet, PostViewSet +from algo_sports.codes.views import ( + JudgementCodeViewSet, + ProgrammingLanguageViewSet, + UserCodeViewSet, +) +from algo_sports.games.views import GameInfoViewSet, GameMatchViewSet, GameRoomViewSet from algo_sports.users.views import UserViewSet if settings.DEBUG: @@ -8,8 +15,24 @@ else: router = SimpleRouter() +# users router.register("users", UserViewSet) +# games app api viewsets +router.register("games/info", GameInfoViewSet) +router.register("games/room", GameRoomViewSet) +router.register("games/match", GameMatchViewSet) + +# codes app api viewsets +router.register("codes/user", UserCodeViewSet) +router.register("codes/judegement", JudgementCodeViewSet) +router.register("codes/programming-language", ProgrammingLanguageViewSet) + +# blogs app api viewsets +router.register("blogs", BlogViewSet) +router.register("posts", PostViewSet) +router.register("comments", CommentViewSet) + app_name = "api" urlpatterns = router.urls diff --git a/config/settings/base.py b/config/settings/base.py index fc740a1..e631d3b 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -75,12 +75,16 @@ "allauth.account", "allauth.socialaccount", "django_celery_beat", + "django_filters", "rest_framework", "rest_framework.authtoken", "corsheaders", + "dj_rest_auth", + "dj_rest_auth.registration", ] LOCAL_APPS = [ + "algo_sports.blogs.apps.BlogConfig", "algo_sports.codes.apps.CodesConfig", "algo_sports.games.apps.GamesConfig", "algo_sports.users.apps.UsersConfig", @@ -285,7 +289,7 @@ # ------------------------------------------------------------------------------ ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True) # https://django-allauth.readthedocs.io/en/latest/configuration.html -ACCOUNT_AUTHENTICATION_METHOD = "username" +ACCOUNT_AUTHENTICATION_METHOD = "email" # https://django-allauth.readthedocs.io/en/latest/configuration.html ACCOUNT_EMAIL_REQUIRED = True # https://django-allauth.readthedocs.io/en/latest/configuration.html @@ -305,12 +309,16 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.TokenAuthentication", + "dj_rest_auth.jwt_auth.JWTCookieAuthentication", ), + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), } # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup CORS_URLS_REGEX = r"^/api/.*$" -# Your stuff... +# dj-rest-auth # ------------------------------------------------------------------------------ +# JWT Support - https://dj-rest-auth.readthedocs.io/en/latest/installation.html#json-web-token-jwt-support-optional +REST_USE_JWT = True +JWT_AUTH_COOKIE = "acookie" diff --git a/config/settings/local.py b/config/settings/local.py index abaaecd..2500ec6 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -11,7 +11,7 @@ default="Md8Ks0BYq4ir3rj1tlsk9wp0y3RkQ1dwG5Fuk8JKBa0kSXxbna7Iyl0ALpPQ4fzh", ) # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] +ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "testserver"] # CACHES # ------------------------------------------------------------------------------ @@ -52,11 +52,52 @@ # django-extensions # ------------------------------------------------------------------------------ # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration -INSTALLED_APPS += ["django_extensions"] # noqa F405 +INSTALLED_APPS += [ + "django_extensions", + "drf_yasg", +] # noqa F405 # Celery # ------------------------------------------------------------------------------ # http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-eager-propagates CELERY_TASK_EAGER_PROPAGATES = True -# Your stuff... + +# django-cors-headers +# ------------------------------------------------------------------------------ +# https://github.com/adamchainz/django-cors-headers +CORS_URLS_REGEX = r"^/api/.*$" + +# django-extensions shell_plus # ------------------------------------------------------------------------------ +# https://django-extensions.readthedocs.io/en/latest/shell_plus.html +SHELL_PLUS_IMPORTS = [ + "from algo_sports.blogs.tests.factories import *", + "from algo_sports.codes.tests.factories import *", + "from algo_sports.games.tests.factories import *", + "from algo_sports.users.tests.factories import *", +] + +# CORS +CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_CREDENTIALS = True + +CORS_ALLOW_METHODS = ( + "DELETE", + "GET", + "OPTIONS", + "PATCH", + "POST", + "PUT", +) + +CORS_ALLOW_HEADERS = ( + "accept", + "accept-encoding", + "authorization", + "content-type", + "dnt", + "origin", + "user-agent", + "x-csrftoken", + "x-requested-with", +) diff --git a/config/settings/production.py b/config/settings/production.py index 49d71cc..1322b0e 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -6,7 +6,7 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env("DJANGO_SECRET_KEY") # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["algosports.com"]) +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["api.asports.kr"]) # DATABASES # ------------------------------------------------------------------------------ @@ -104,7 +104,7 @@ # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email DEFAULT_FROM_EMAIL = env( - "DJANGO_DEFAULT_FROM_EMAIL", default="algo_sports " + "DJANGO_DEFAULT_FROM_EMAIL", default="algo_sports " ) # https://docs.djangoproject.com/en/dev/ref/settings/#server-email SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) @@ -123,8 +123,10 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend # https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference # https://anymail.readthedocs.io/en/stable/esps/amazon_ses/ -EMAIL_BACKEND = "anymail.backends.amazon_ses.EmailBackend" -# ANYMAIL = {} +EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" +ANYMAIL = { + "MAILGUN_API_KEY": env("MAILGUN_API_KEY"), +} # django-compressor # ------------------------------------------------------------------------------ diff --git a/config/settings/test.py b/config/settings/test.py index 83c2f5b..b3914e7 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -12,6 +12,9 @@ "DJANGO_SECRET_KEY", default="MVSd8gpW9BYJBi1UZ8Ar11JUqecYPojiGzH9vJ25dE7q0eqN3uAIoZVtSQ0CvSdR", ) +# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = ["testserver", "localhost", "0.0.0.0", "127.0.0.1"] + # https://docs.djangoproject.com/en/dev/ref/settings/#test-runner TEST_RUNNER = "django.test.runner.DiscoverRunner" diff --git a/config/swagger_router.py b/config/swagger_router.py new file mode 100644 index 0000000..783e108 --- /dev/null +++ b/config/swagger_router.py @@ -0,0 +1,33 @@ +from django.urls import re_path +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions + +schema_view = get_schema_view( + openapi.Info( + title="Algo-sports API", + default_version="v1", + description="Test description", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="contact@snippets.local"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + +urlpatterns = [ + re_path( + r"^swagger(?P\.json|\.yaml)$", + schema_view.without_ui(cache_timeout=0), + name="schema-json", + ), + re_path( + r"^swagger/$", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + re_path( + r"^redoc/$", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc" + ), +] diff --git a/config/urls.py b/config/urls.py index 6f1d427..17c4022 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,13 +1,24 @@ +from dj_rest_auth.registration.views import ConfirmEmailView from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns -from django.urls import include, path +from django.urls import include, path, re_path from django.views import defaults as default_views -from rest_framework.authtoken.views import obtain_auth_token +from django.views.generic import TemplateView urlpatterns = [ + path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), + path( + "about/", TemplateView.as_view(template_name="pages/about.html"), name="about" + ), + # Django Admin, use {% raw %}{% url 'admin:index' %}{% endraw %} path(settings.ADMIN_URL, admin.site.urls), + # User management + path( + "users/", + include("algo_sports.users.urls", namespace="users"), + ), path("accounts/", include("allauth.urls")), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.DEBUG: @@ -18,10 +29,18 @@ urlpatterns += [ # API base url path("api/", include("config.api_router")), - # DRF auth token - path("auth-token/", obtain_auth_token), + path("api/auth/", include("dj_rest_auth.urls")), + re_path( + r"^api/auth/registration/account-confirm-email/(?P[-:\w]+)/$", + ConfirmEmailView.as_view(), + name="account_confirm_email", + ), + path("api/auth/registration/", include("dj_rest_auth.registration.urls")), ] +# Swagger URLS +urlpatterns += [path("", include("config.swagger_router"))] + if settings.DEBUG: # This allows the error pages to be debugged during development, just visit # these url in browser to see how these error pages look like. diff --git "a/docs/IAM_\354\240\225\354\261\205.png" "b/docs/IAM_\354\240\225\354\261\205.png" new file mode 100644 index 0000000..f07fb1d Binary files /dev/null and "b/docs/IAM_\354\240\225\354\261\205.png" differ diff --git a/fabfile.py b/fabfile.py index 3b976b9..b625138 100644 --- a/fabfile.py +++ b/fabfile.py @@ -1,4 +1,5 @@ -from fabric.api import env, local +from fabric.api import env +from fabric.api import local as fabric_local from fabric.context_managers import shell_env DEV = "dev" @@ -8,43 +9,74 @@ # 사용할 환경 env.target = DEV -env.USE_DOCKER = "no" +env.compose = "local.yml" -def docker(): - env.USE_DOCKER = "yes" +# 커스텀 local 메소드 +# ---------------------------------------------------------------- +def local(string): + if env.target == STAGING: + fabric_local(string) + elif env.target == PRODUCTION: + fabric_local(string) + elif env.target == DEV: + with shell_env(DJANGO_READ_DOT_ENV_FILE="True"): + fabric_local(string) +# 환경 세팅 커맨드 +# ---------------------------------------------------------------- def dev(): env.target = DEV + env.compose = "local.yml" def staging(): env.target = STAGING + env.compose = "staging.yml" def prod(): env.target = PRODUCTION + env.compose = "production.yml" -def build(): - if env.target == DEV: - local("docker-compose -f local.yml build") - elif env.target in [STAGING, PRODUCTION]: - local("docker-compose -f production.yml build") +# 장고 커맨드 +# ---------------------------------------------------------------- +def makemessages(locale): + # locale에 해당하는 번역 파일 생성 + local(f"./manage.py makemessages -i venv -l {locale}") def runserver(): - if env.target == DEV: - if env.USE_DOCKER == "yes": - local("docker-compose -f local.yml up") - with shell_env(DJANGO_READ_DOT_ENV_FILE="True"): - local("./manage.py migrate") - local("./manage.py runserver") + local("python manage.py runserver") -def makemessages(locale): - if locale: - local(f"./manage.py makemessages -i venv -l {locale}") - else: - print("usage: fab makemessages:locale") +def load_languages(): + local( + "python manage.py loaddata algo_sports/codes/fixtures/programming_language.json" + ) + + +def shell(): + local("python manage.py shell_plus") + + +# Celery worker +# ---------------------------------------------------------------- +def celery(): + local("celery -A config.celery_app worker -l info") + + +# Docker 커맨드 +# ---------------------------------------------------------------- +def build(): + local(f"docker-compose -f {env.compose} build") + + +def up(app="", option=""): + local(f"docker-compose -f {env.compose} up {option} {app}") + + +def down(app="", option=""): + local(f"docker-compose -f {env.compose} down {option} {app}") diff --git a/local.yml b/local.yml index 62ed711..9b98bbe 100644 --- a/local.yml +++ b/local.yml @@ -1,4 +1,4 @@ -version: '3' +version: "3" volumes: local_postgres_data: {} @@ -33,3 +33,39 @@ services: - local_postgres_data_backups:/backups:z env_file: - ./.envs/.local/.postgres + + redis: + image: redis:5.0 + container_name: redis + ports: ["6379"] + + + celeryworker: + <<: *django + image: my_awesome_project_local_celeryworker + container_name: celeryworker + depends_on: + - redis + - postgres + + ports: [] + command: /start-celeryworker + + celerybeat: + <<: *django + image: my_awesome_project_local_celerybeat + container_name: celerybeat + depends_on: + - redis + - postgres + + ports: [] + command: /start-celerybeat + + flower: + <<: *django + image: my_awesome_project_local_flower + container_name: flower + ports: + - "5555:5555" + command: /start-flower diff --git a/production.yml b/production.yml index 5b8bf7e..150c7e5 100644 --- a/production.yml +++ b/production.yml @@ -1,31 +1,41 @@ -version: '3' - -volumes: - production_postgres_data: {} - production_postgres_data_backups: {} - production_traefik: {} +version: "3" services: django: &django - build: - context: . - dockerfile: ./compose/production/django/Dockerfile - image: algo_sports_production_django + logging: + driver: awslogs + options: + awslogs-group: algo-sports + awslogs-region: ap-northeast-2 + awslogs-stream-prefix: django + + nginx: + logging: + driver: awslogs + options: + awslogs-group: algo-sports + awslogs-region: ap-northeast-2 + awslogs-stream-prefix: nginx depends_on: - - postgres - - redis - env_file: - - ./.envs/.production/.django - - ./.envs/.production/.postgres - command: /start + - django + + redis: + image: redis:5.0 + + celeryworker: + <<: *django + logging: + driver: awslogs + options: + awslogs-group: algo-sports + awslogs-region: ap-northeast-2 + awslogs-stream-prefix: celeryworker - postgres: - build: - context: . - dockerfile: ./compose/production/postgres/Dockerfile - image: algo_sports_production_postgres - volumes: - - production_postgres_data:/var/lib/postgresql/data:Z - - production_postgres_data_backups:/backups:z - env_file: - - ./.envs/.production/.postgres + flower: + <<: *django + logging: + driver: awslogs + options: + awslogs-group: algo-sports + awslogs-region: ap-northeast-2 + awslogs-stream-prefix: flower diff --git a/requirements/base.txt b/requirements/base.txt index fcfda39..0ebc8db 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,12 +8,13 @@ hiredis==1.1.0 # https://github.com/redis/hiredis-py celery==4.4.6 # pyup: < 5.0,!=4.4.7 # https://github.com/celery/celery django-celery-beat==2.0.0 # https://github.com/celery/django-celery-beat flower==0.9.5 # https://github.com/mher/flower -uvicorn==0.12.1 # https://github.com/encode/uvicorn +uvicorn==0.13.1 # https://github.com/encode/uvicorn wsproto==0.15.0 # https://github.com/python-hyper/wsproto/ +numpy==1.19.4 # # Django # ------------------------------------------------------------------------------ -django==3.0.10 # pyup: < 3.1 # https://www.djangoproject.com/ +django==3.1.3 # pyup: < 3.1 # https://www.djangoproject.com/ django-environ==0.4.5 # https://github.com/joke2k/django-environ django-model-utils==4.0.0 # https://github.com/jazzband/django-model-utils django-allauth==0.43.0 # https://github.com/pennersr/django-allauth @@ -23,3 +24,11 @@ django-redis==4.12.1 # https://github.com/jazzband/django-redis # Django REST Framework djangorestframework==3.12.1 # https://github.com/encode/django-rest-framework django-cors-headers==3.5.0 # https://github.com/adamchainz/django-cors-headers +dj-rest-auth==2.0.1 # https://github.com/jazzband/dj-rest-auth +djangorestframework-simplejwt==4.6.0 # https://github.com/SimpleJWT/django-rest-framework-simplejwt +django-filter==2.4.0 +# drf-yasg # https://drf-yasg.readthedocs.io/en/stable/readme.html +drf-yasg==1.20.0 +inflection==0.5.1 +ruamel.yaml==0.16.12 +ruamel.yaml.clib==0.2.2 diff --git a/requirements/local.txt b/requirements/local.txt index dd41f9c..405182a 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -7,9 +7,9 @@ watchgod==0.6 # https://github.com/samuelcolvin/watchgod # Testing # ------------------------------------------------------------------------------ -mypy==0.782 # https://github.com/python/mypy -django-stubs==1.6.0 # https://github.com/typeddjango/django-stubs -pytest==6.1.1 # https://github.com/pytest-dev/pytest +mypy==0.790 # https://github.com/python/mypy +django-stubs==1.7.0 # https://github.com/typeddjango/django-stubs +pytest==6.1.2 # https://github.com/pytest-dev/pytest pytest-sugar==0.9.4 # https://github.com/Frozenball/pytest-sugar # Documentation diff --git a/requirements/production.txt b/requirements/production.txt index 2ab9e7b..677e703 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -3,6 +3,8 @@ -r base.txt gunicorn==20.0.4 # https://github.com/benoitc/gunicorn +uvloop==0.14.0 +httptools==0.1.1 psycopg2==2.8.6 # https://github.com/psycopg/psycopg2 Collectfast==2.2.0 # https://github.com/antonagestam/collectfast diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..2666abf --- /dev/null +++ b/run.sh @@ -0,0 +1,33 @@ +#!/bin/bash +while [[ $# -gt 0 ]]; do + key="$1" + case $key in + -s | --source) + SOURCE_FILE="$2" + shift + shift + ;; + -r | --run-cmd) + RUN_CMD="$2" + shift + shift + ;; + -c | --compile-cmd) + COMPILE_CMD="$2" + shift + shift + ;; + *) + echo "Unknown option $key" + echo "Usage: ./run [--language ] [--isolate]" + exit -1 + ;; + esac +done + +if [[ $COMPILE_CMD != "" ]]; then + bash -c "$COMPILE_CMD $SOURCE_FILE" + bash -c "$RUN_CMD $SOURCE_FILE" +else + echo "" | bash -c "$RUN_CMD $SOURCE_FILE" +fi diff --git a/staging.yml b/staging.yml new file mode 100644 index 0000000..abf4ece --- /dev/null +++ b/staging.yml @@ -0,0 +1,37 @@ +version: "3" + +services: + django: &django + build: + context: . + dockerfile: ./compose/production/django/Dockerfile + image: 648240308375.dkr.ecr.ap-northeast-2.amazonaws.com/django + env_file: + - ./.envs/.production/.django + - ./.envs/.production/.postgres + command: /start + + nginx: + build: + context: ./compose/production/nginx + dockerfile: ./Dockerfile + image: 648240308375.dkr.ecr.ap-northeast-2.amazonaws.com/nginx + ports: + - "80:80" + depends_on: + - django + links: + - django + + redis: + image: redis:5.0 + ports: + - "6379:6379" + + celeryworker: + <<: *django + command: /start-celeryworker + + flower: + <<: *django + command: /start-flower diff --git a/user_data.sh b/user_data.sh new file mode 100644 index 0000000..f32fdde --- /dev/null +++ b/user_data.sh @@ -0,0 +1,2 @@ +#/bin/bash date -d +echo ECS_CLUSTER=jts-cluster >> /etc/ecs/ecs.config